feat(cron): improve scheduled task delivery, channel history sync, and reminder parsing

- Add DingTalk delivery route resolution and caching for scheduled task replies
- Extract shared scheduled reminder text parsing into src/common/
- Patch OpenClaw runtime cron delivery inference to support deliveryContext
- Improve channel history sync with tail-overlap reconciliation strategy
- Harden IM session tracking with DB fallback via ensureTrackedSession
- Disable web_search tool for managed sessions and add browser/web_fetch guidance
- Guard against stale loadSessions responses in renderer
- Normalize scheduled reminder display text in CoworkSessionDetail
This commit is contained in:
fisherdaddy
2026-03-16 02:01:08 +08:00
parent ca1242e8fb
commit 4f2bfa217f
23 changed files with 1536 additions and 229 deletions
+1 -1
View File
@@ -15,5 +15,5 @@
"*": ["node_modules/*"]
}
},
"include": ["src/main"]
"include": ["src/main", "src/common"]
}
+92
View File
@@ -0,0 +1,92 @@
export type ScheduledReminderPrompt = {
reminderText: string;
currentTime?: string;
};
const SCHEDULED_REMINDER_PREFIX = 'A scheduled reminder has been triggered. The reminder content is:';
const SCHEDULED_REMINDER_INTERNAL_INSTRUCTION = 'Handle this reminder internally. Do not relay it to the user unless explicitly requested.';
const SCHEDULED_REMINDER_RELAY_INSTRUCTION = 'Please relay this reminder to the user in a helpful and friendly way.';
const CURRENT_TIME_PREFIX = 'Current time:';
const LEGACY_SYSTEM_LINE_RE = /^System:\s*(?:\[(.+?)\]\s*)?(⏰.+)$/u;
const SIMPLE_REMINDER_RE = /^⏰(?:\s|$)/u;
export function parseScheduledReminderPrompt(text: string): ScheduledReminderPrompt | null {
const trimmed = text.trim();
if (!trimmed.startsWith(SCHEDULED_REMINDER_PREFIX)) {
return null;
}
let remainder = trimmed.slice(SCHEDULED_REMINDER_PREFIX.length).trim();
let currentTime: string | undefined;
const currentTimeIndex = remainder.lastIndexOf(CURRENT_TIME_PREFIX);
if (currentTimeIndex >= 0) {
currentTime = remainder.slice(currentTimeIndex + CURRENT_TIME_PREFIX.length).trim() || undefined;
remainder = remainder.slice(0, currentTimeIndex).trim();
}
if (remainder.endsWith(SCHEDULED_REMINDER_INTERNAL_INSTRUCTION)) {
remainder = remainder.slice(0, -SCHEDULED_REMINDER_INTERNAL_INSTRUCTION.length).trim();
} else if (remainder.endsWith(SCHEDULED_REMINDER_RELAY_INSTRUCTION)) {
remainder = remainder.slice(0, -SCHEDULED_REMINDER_RELAY_INSTRUCTION.length).trim();
}
if (!remainder) {
return null;
}
return {
reminderText: remainder,
...(currentTime ? { currentTime } : {}),
};
}
export function parseLegacyScheduledReminderSystemMessage(text: string): ScheduledReminderPrompt | null {
const trimmed = text.trim();
const firstLine = trimmed.split(/\r?\n/u, 1)[0]?.trim() ?? '';
const match = firstLine.match(LEGACY_SYSTEM_LINE_RE);
if (!match) {
return null;
}
const rest = trimmed.slice(firstLine.length).trim();
const wrappedPrompt = rest ? parseScheduledReminderPrompt(rest) : null;
return wrappedPrompt ?? {
reminderText: match[2].trim(),
...(match[1]?.trim() ? { currentTime: match[1].trim() } : {}),
};
}
export function isSimpleScheduledReminderText(text: string): boolean {
return SIMPLE_REMINDER_RE.test(text.trim());
}
export function parseSimpleScheduledReminderText(text: string): ScheduledReminderPrompt | null {
const trimmed = text.trim();
if (!isSimpleScheduledReminderText(trimmed)) {
return null;
}
return {
reminderText: trimmed,
};
}
export function getScheduledReminderDisplayText(text: string): string | null {
const prompt = parseScheduledReminderPrompt(text);
if (prompt) {
return prompt.reminderText;
}
const legacy = parseLegacyScheduledReminderSystemMessage(text);
if (legacy) {
return legacy.reminderText;
}
const simple = parseSimpleScheduledReminderText(text);
if (simple) {
return simple.reminderText;
}
return null;
}
+32 -22
View File
@@ -10,7 +10,7 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk';
import type { CoworkRuntime, PermissionRequest } from '../libs/agentEngine/types';
import type { CoworkStore, CoworkMessage } from '../coworkStore';
import type { IMStore } from './imStore';
import type { IMMessage, IMPlatform, IMMediaAttachment } from './types';
import type { IMMessage, IMPlatform, IMMediaAttachment, IMSessionMapping } from './types';
import { buildIMMediaInstruction } from './imMediaInstruction';
import { analyzeIMReply } from './imReplyGuard';
import {
@@ -108,14 +108,32 @@ export class IMCoworkHandler extends EventEmitter {
if (!session) {
continue;
}
this.imSessionIds.add(mapping.coworkSessionId);
this.sessionConversationMap.set(mapping.coworkSessionId, {
conversationId: mapping.imConversationId,
platform: mapping.platform,
});
this.trackSessionMapping(mapping);
}
}
private trackSessionMapping(mapping: IMSessionMapping): void {
this.imSessionIds.add(mapping.coworkSessionId);
this.sessionConversationMap.set(mapping.coworkSessionId, {
conversationId: mapping.imConversationId,
platform: mapping.platform,
});
}
private ensureTrackedSession(sessionId: string): boolean {
if (this.imSessionIds.has(sessionId)) {
return true;
}
const mapping = this.imStore.getSessionMappingByCoworkSessionId(sessionId);
if (!mapping) {
return false;
}
this.trackSessionMapping(mapping);
return true;
}
/**
* Set up event listeners for CoworkRuntime
*/
@@ -275,11 +293,7 @@ export class IMCoworkHandler extends EventEmitter {
this.coworkRuntime.stopSession(existing.coworkSessionId);
} else {
this.imStore.updateSessionLastActive(imConversationId, platform);
this.imSessionIds.add(existing.coworkSessionId);
this.sessionConversationMap.set(existing.coworkSessionId, {
conversationId: imConversationId,
platform,
});
this.trackSessionMapping(existing);
return existing.coworkSessionId;
}
}
@@ -316,12 +330,8 @@ export class IMCoworkHandler extends EventEmitter {
);
// Save mapping
this.imStore.createSessionMapping(imConversationId, platform, session.id);
this.imSessionIds.add(session.id);
this.sessionConversationMap.set(session.id, {
conversationId: imConversationId,
platform,
});
const mapping = this.imStore.createSessionMapping(imConversationId, platform, session.id);
this.trackSessionMapping(mapping);
return session.id;
}
@@ -525,7 +535,7 @@ export class IMCoworkHandler extends EventEmitter {
*/
private handleMessage(sessionId: string, message: CoworkMessage): void {
// Only process messages from IM sessions
if (!this.imSessionIds.has(sessionId)) return;
if (!this.ensureTrackedSession(sessionId)) return;
const accumulator = this.messageAccumulators.get(sessionId) ?? this.ensureBackgroundAccumulator(sessionId);
if (accumulator) {
@@ -538,7 +548,7 @@ export class IMCoworkHandler extends EventEmitter {
*/
private handleMessageUpdate(sessionId: string, messageId: string, content: string): void {
// Only process updates from IM sessions
if (!this.imSessionIds.has(sessionId)) return;
if (!this.ensureTrackedSession(sessionId)) return;
const accumulator = this.messageAccumulators.get(sessionId);
if (accumulator) {
@@ -749,7 +759,7 @@ export class IMCoworkHandler extends EventEmitter {
*/
private handlePermissionRequest(sessionId: string, request: PermissionRequest): void {
// Only process permission requests from IM sessions
if (!this.imSessionIds.has(sessionId)) return;
if (!this.ensureTrackedSession(sessionId)) return;
const conversation = this.sessionConversationMap.get(sessionId);
if (!conversation) {
this.coworkRuntime.respondToPermission(request.requestId, {
@@ -803,7 +813,7 @@ export class IMCoworkHandler extends EventEmitter {
*/
private handleComplete(sessionId: string): void {
// Only process complete events from IM sessions
if (!this.imSessionIds.has(sessionId)) return;
if (!this.ensureTrackedSession(sessionId)) return;
this.clearPendingPermissionsBySessionId(sessionId);
const accumulator = this.messageAccumulators.get(sessionId);
@@ -856,7 +866,7 @@ export class IMCoworkHandler extends EventEmitter {
*/
private handleError(sessionId: string, error: string): void {
// Only process error events from IM sessions
if (!this.imSessionIds.has(sessionId)) return;
if (!this.ensureTrackedSession(sessionId)) return;
this.clearPendingPermissionsBySessionId(sessionId);
const accumulator = this.messageAccumulators.get(sessionId);
+133
View File
@@ -0,0 +1,133 @@
import {
buildManagedSessionKey,
DEFAULT_MANAGED_AGENT_ID,
} from '../libs/openclawChannelSessionSync';
export interface OpenClawDeliveryRoute {
channel: string;
to: string;
accountId?: string;
}
type OpenClawSessionListEntry = {
key?: unknown;
deliveryContext?: unknown;
lastChannel?: unknown;
lastTo?: unknown;
lastAccountId?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
};
const readTrimmedString = (value: unknown): string | undefined => {
if (typeof value !== 'string') {
return undefined;
}
const normalized = value.trim();
return normalized || undefined;
};
export function extractOpenClawDeliveryRoute(entry: unknown): OpenClawDeliveryRoute | null {
if (!isRecord(entry)) {
return null;
}
const typedEntry = entry as OpenClawSessionListEntry;
const deliveryContext = isRecord(typedEntry.deliveryContext)
? typedEntry.deliveryContext
: null;
const channel = readTrimmedString(deliveryContext?.channel) ?? readTrimmedString(typedEntry.lastChannel);
const to = readTrimmedString(deliveryContext?.to) ?? readTrimmedString(typedEntry.lastTo);
const accountId = readTrimmedString(deliveryContext?.accountId) ?? readTrimmedString(typedEntry.lastAccountId);
if (!channel || !to) {
return null;
}
return {
channel,
to,
...(accountId ? { accountId } : {}),
};
}
export function findOpenClawDeliveryRouteForSession(
sessionKey: string,
sessions: unknown[],
): OpenClawDeliveryRoute | null {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey) {
return null;
}
for (const entry of sessions) {
if (!isRecord(entry)) {
continue;
}
const entryKey = readTrimmedString(entry.key);
if (entryKey !== normalizedSessionKey) {
continue;
}
return extractOpenClawDeliveryRoute(entry);
}
return null;
}
export function resolveOpenClawDeliveryRouteForSessionKeys(
sessionKeys: string[],
sessions: unknown[],
): { sessionKey: string; route: OpenClawDeliveryRoute } | null {
const seen = new Set<string>();
for (const rawSessionKey of sessionKeys) {
const sessionKey = rawSessionKey.trim();
if (!sessionKey || seen.has(sessionKey)) {
continue;
}
seen.add(sessionKey);
const route = findOpenClawDeliveryRouteForSession(sessionKey, sessions);
if (route) {
return { sessionKey, route };
}
}
return null;
}
export function resolveManagedSessionDeliveryRoute(
coworkSessionId: string,
sessions: unknown[],
): { sessionKey: string; route: OpenClawDeliveryRoute } | null {
return resolveOpenClawDeliveryRouteForSessionKeys([buildManagedSessionKey(coworkSessionId)], sessions);
}
export function buildDingTalkSendParamsFromRoute(
route: OpenClawDeliveryRoute,
): { target: string; accountId?: string } | null {
const channel = route.channel.trim().toLowerCase();
if (channel !== 'dingtalk-connector' && channel !== 'dingtalk') {
return null;
}
return {
target: route.to,
...(route.accountId ? { accountId: route.accountId } : {}),
};
}
export function buildDingTalkSessionKeyCandidates(conversationId: string): string[] {
const normalizedConversationId = conversationId.trim();
if (!normalizedConversationId) {
return [];
}
return [
`agent:${DEFAULT_MANAGED_AGENT_ID}:openai-user:dingtalk-connector:${normalizedConversationId}`,
`agent:${DEFAULT_MANAGED_AGENT_ID}:dingtalk-connector:${normalizedConversationId}`,
`dingtalk-connector:${normalizedConversationId}`,
];
}
+278
View File
@@ -18,6 +18,13 @@ import type {
ParsedIMScheduledTaskRequest,
} from './imScheduledTaskHandler';
import { createIMScheduledTaskRequestDetector } from './imScheduledTaskHandler';
import {
buildDingTalkSessionKeyCandidates,
buildDingTalkSendParamsFromRoute,
type OpenClawDeliveryRoute,
resolveManagedSessionDeliveryRoute,
resolveOpenClawDeliveryRouteForSessionKeys,
} from './imDeliveryRoute';
import { fetchJsonWithTimeout } from './http';
import {
IMGatewayConfig,
@@ -34,6 +41,18 @@ import type { CoworkStore } from '../coworkStore';
const CONNECTIVITY_TIMEOUT_MS = 10_000;
const INBOUND_ACTIVITY_WARN_AFTER_MS = 2 * 60 * 1000;
type GatewayClientLike = {
request: <T = Record<string, unknown>>(
method: string,
params?: unknown,
opts?: { expectFinal?: boolean },
) => Promise<T>;
};
interface OpenClawSessionsListResult {
sessions?: unknown[];
}
interface TelegramGetMeResponse {
ok?: boolean;
result?: {
@@ -54,6 +73,9 @@ export interface IMGatewayManagerOptions {
isOpenClawEngine?: () => boolean;
syncOpenClawConfig?: () => Promise<void>;
ensureOpenClawGatewayConnected?: () => Promise<void>;
getOpenClawGatewayClient?: () => GatewayClientLike | null;
ensureOpenClawGatewayReady?: () => Promise<void>;
getOpenClawSessionKeysForCoworkSession?: (sessionId: string) => string[];
createScheduledTask?: (params: {
sessionId: string;
message: IMMessage;
@@ -73,6 +95,9 @@ export class IMGatewayManager extends EventEmitter {
private isOpenClawEngine: (() => boolean) | null = null;
private syncOpenClawConfig: (() => Promise<void>) | null = null;
private ensureOpenClawGatewayConnected: (() => Promise<void>) | null = null;
private getOpenClawGatewayClient: (() => GatewayClientLike | null) | null = null;
private ensureOpenClawGatewayReady: (() => Promise<void>) | null = null;
private getOpenClawSessionKeysForCoworkSession: ((sessionId: string) => string[]) | null = null;
private createScheduledTask:
| ((params: {
sessionId: string;
@@ -104,6 +129,9 @@ export class IMGatewayManager extends EventEmitter {
this.isOpenClawEngine = options?.isOpenClawEngine ?? null;
this.syncOpenClawConfig = options?.syncOpenClawConfig ?? null;
this.ensureOpenClawGatewayConnected = options?.ensureOpenClawGatewayConnected ?? null;
this.getOpenClawGatewayClient = options?.getOpenClawGatewayClient ?? null;
this.ensureOpenClawGatewayReady = options?.ensureOpenClawGatewayReady ?? null;
this.getOpenClawSessionKeysForCoworkSession = options?.getOpenClawSessionKeysForCoworkSession ?? null;
this.createScheduledTask = options?.createScheduledTask ?? null;
// Forward gateway events
@@ -1614,6 +1642,27 @@ export class IMGatewayManager extends EventEmitter {
async sendConversationReply(platform: IMPlatform, conversationId: string, text: string): Promise<boolean> {
try {
switch (platform) {
case 'dingtalk': {
const target = await this.resolveDingTalkConversationReplyTarget(conversationId)
?? this.parseDingTalkConversationTarget(conversationId);
if (!target) {
console.warn(`[IMGatewayManager] Cannot resolve DingTalk target from conversationId: ${conversationId}`);
return false;
}
await this.requestOpenClawGateway('dingtalk-connector.send', {
...(target.accountId ? { accountId: target.accountId } : {}),
target: target.target,
content: text,
useAICard: false,
fallbackToNormal: true,
});
this.cacheConversationReplyRoute('dingtalk', conversationId, {
channel: 'dingtalk-connector',
to: target.target,
...(target.accountId ? { accountId: target.accountId } : {}),
});
return true;
}
case 'nim':
await this.nimGateway.sendConversationNotification(conversationId, text);
return true;
@@ -1629,6 +1678,235 @@ export class IMGatewayManager extends EventEmitter {
}
}
async primeConversationReplyRoute(
platform: IMPlatform,
conversationId: string,
coworkSessionId: string,
): Promise<void> {
if (platform !== 'dingtalk') {
return;
}
try {
const lookup = await this.lookupDingTalkConversationReplyRoute(conversationId, coworkSessionId);
const resolved = lookup?.resolved;
if (!resolved) {
return;
}
this.cacheConversationReplyRoute('dingtalk', conversationId, resolved.route);
const sendParams = buildDingTalkSendParamsFromRoute(resolved.route);
console.log('[IMGatewayManager] Primed DingTalk reply route', JSON.stringify({
conversationId,
coworkSessionId: lookup.coworkSessionId,
sessionKey: resolved.sessionKey,
channel: resolved.route.channel,
target: sendParams?.target ?? resolved.route.to,
accountId: sendParams?.accountId ?? resolved.route.accountId ?? null,
}));
} catch (error: any) {
console.warn(
`[IMGatewayManager] Failed to prime DingTalk reply route for ${conversationId}:`,
error?.message || error,
);
}
}
private async resolveDingTalkConversationReplyTarget(
conversationId: string,
): Promise<{ accountId?: string; target: string } | null> {
let lookup: Awaited<ReturnType<IMGatewayManager['lookupDingTalkConversationReplyRoute']>> = null;
try {
lookup = await this.lookupDingTalkConversationReplyRoute(conversationId);
} catch (error: any) {
console.warn(
`[IMGatewayManager] Failed to query OpenClaw DingTalk reply route for ${conversationId}:`,
error?.message || error,
);
}
if (!lookup?.resolved) {
if (lookup) {
console.warn(
`[IMGatewayManager] No OpenClaw delivery route found for DingTalk session ${lookup.coworkSessionId}`,
JSON.stringify({
conversationId,
candidateSessionKeys: lookup.candidateSessionKeys,
dingtalkSessionKeys: lookup.dingtalkSessionKeys,
}),
);
}
const cachedRoute = this.imStore.getConversationReplyRoute('dingtalk', conversationId);
if (cachedRoute) {
const cachedSendParams = buildDingTalkSendParamsFromRoute(cachedRoute);
if (cachedSendParams) {
console.log('[IMGatewayManager] Reused cached DingTalk reply route', JSON.stringify({
conversationId,
channel: cachedRoute.channel,
target: cachedSendParams.target,
accountId: cachedSendParams.accountId ?? null,
}));
return cachedSendParams;
}
}
return null;
}
const { resolved } = lookup;
this.cacheConversationReplyRoute('dingtalk', conversationId, resolved.route);
const sendParams = buildDingTalkSendParamsFromRoute(resolved.route);
if (!sendParams) {
console.warn(
`[IMGatewayManager] OpenClaw route for ${resolved.sessionKey} is not a DingTalk route: ${resolved.route.channel}`,
);
return null;
}
console.log('[IMGatewayManager] Resolved DingTalk reply route', JSON.stringify({
conversationId,
coworkSessionId: lookup.coworkSessionId,
sessionKey: resolved.sessionKey,
channel: resolved.route.channel,
target: sendParams.target,
accountId: sendParams.accountId ?? null,
}));
return sendParams;
}
private async lookupDingTalkConversationReplyRoute(
conversationId: string,
coworkSessionId?: string,
): Promise<{
coworkSessionId: string;
candidateSessionKeys: string[];
dingtalkSessionKeys: string[];
resolved: { sessionKey: string; route: OpenClawDeliveryRoute } | null;
} | null> {
const normalizedCoworkSessionId = coworkSessionId?.trim()
|| this.imStore.getSessionMapping(conversationId, 'dingtalk')?.coworkSessionId
|| '';
if (!normalizedCoworkSessionId) {
return null;
}
const result = await this.requestOpenClawGateway<OpenClawSessionsListResult>('sessions.list', {
includeGlobal: true,
includeUnknown: true,
limit: 200,
});
const sessions = Array.isArray(result?.sessions) ? result.sessions : [];
const candidateSessionKeys = [
...(this.getOpenClawSessionKeysForCoworkSession?.(normalizedCoworkSessionId) ?? []),
...buildDingTalkSessionKeyCandidates(conversationId),
];
return {
coworkSessionId: normalizedCoworkSessionId,
candidateSessionKeys,
dingtalkSessionKeys: this.collectSessionKeysByChannel(sessions, 'dingtalk-connector'),
resolved: resolveOpenClawDeliveryRouteForSessionKeys(candidateSessionKeys, sessions)
?? resolveManagedSessionDeliveryRoute(normalizedCoworkSessionId, sessions),
};
}
private cacheConversationReplyRoute(
platform: IMPlatform,
conversationId: string,
route: OpenClawDeliveryRoute,
): void {
this.imStore.setConversationReplyRoute(platform, conversationId, route);
}
private collectSessionKeysByChannel(sessions: unknown[], channel: string): string[] {
const normalizedChannel = channel.trim().toLowerCase();
const matches: string[] = [];
for (const entry of sessions) {
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
continue;
}
const record = entry as Record<string, unknown>;
const key = typeof record.key === 'string' ? record.key.trim() : '';
if (!key) {
continue;
}
const deliveryContext = record.deliveryContext;
const deliveryChannel = deliveryContext && typeof deliveryContext === 'object' && !Array.isArray(deliveryContext)
? (typeof (deliveryContext as Record<string, unknown>).channel === 'string'
? ((deliveryContext as Record<string, unknown>).channel as string)
: undefined)
: undefined;
const lastChannel = typeof record.lastChannel === 'string' ? record.lastChannel : undefined;
const routeChannel = (deliveryChannel ?? lastChannel ?? '').trim().toLowerCase();
if (routeChannel !== normalizedChannel && !key.toLowerCase().includes(normalizedChannel)) {
continue;
}
matches.push(key);
if (matches.length >= 12) {
break;
}
}
return matches;
}
private parseDingTalkConversationTarget(
conversationId: string,
): { accountId?: string; target: string } | null {
const parts = conversationId.split(':').filter(Boolean);
if (parts.length < 2) {
return null;
}
let accountId = parts[0]?.trim();
if (!accountId) {
return null;
}
// The dingtalk-connector plugin uses "__default__" as the internal account
// alias in conversationIds. The send API expects the actual clientId, so
// resolve the alias from the persisted DingTalk config.
if (accountId === '__default__') {
const dtConfig = this.imStore.getDingTalkOpenClawConfig();
if (dtConfig.clientId) {
accountId = dtConfig.clientId;
}
}
if ((parts[1] === 'user' || parts[1] === 'group') && parts[2]) {
return {
accountId,
target: `${parts[1]}:${parts.slice(2).join(':')}`,
};
}
const senderId = parts[1]?.trim();
if (!senderId) {
return null;
}
return {
accountId,
target: `user:${senderId}`,
};
}
private async requestOpenClawGateway<T = Record<string, unknown>>(
method: string,
params?: unknown,
): Promise<T> {
let client = this.getOpenClawGatewayClient?.() ?? null;
if (!client) {
await this.ensureOpenClawGatewayReady?.();
client = this.getOpenClawGatewayClient?.() ?? null;
}
if (!client) {
throw new Error('OpenClaw gateway client is unavailable.');
}
return client.request<T>(method, params);
}
private resolveFeishuDomain(domain: string, Lark: any): any {
if (domain === 'lark') return Lark.Domain.Lark;
if (domain === 'feishu') return Lark.Domain.Feishu;
+10 -2
View File
@@ -1,6 +1,11 @@
import type { IMMediaAttachment, IMMessage } from './types';
import { IMChatHandler } from './imChatHandler';
import { buildOpenClawLocalTimeContextPrompt } from '../libs/openclawLocalTimeContextPrompt';
import {
parseSimpleScheduledReminderText,
parseLegacyScheduledReminderSystemMessage,
parseScheduledReminderPrompt,
} from '../../common/scheduledReminderText';
function pad(value: number): string {
return String(value).padStart(2, '0');
@@ -269,9 +274,12 @@ export function createIMScheduledTaskRequestDetector(options: {
export function isReminderSystemTurn(messages: Array<{ type: string; content: string }>): boolean {
return messages.some((message) => {
const content = typeof message.content === 'string' ? message.content : '';
if (message.type === 'system') {
return true;
return parseSimpleScheduledReminderText(content) !== null
|| parseLegacyScheduledReminderSystemMessage(content) !== null;
}
return /A scheduled reminder has been triggered\.|^System:\s*\[/u.test(message.content);
return parseScheduledReminderPrompt(content) !== null
|| parseSimpleScheduledReminderText(content) !== null;
});
}
+50
View File
@@ -29,6 +29,12 @@ import {
DEFAULT_IM_SETTINGS,
} from './types';
interface StoredConversationReplyRoute {
channel: string;
to: string;
accountId?: string;
}
export class IMStore {
private db: Database;
private saveDb: () => void;
@@ -529,6 +535,31 @@ export class IMStore {
this.setConfigValue(`notification_target:${platform}`, target);
}
getConversationReplyRoute(
platform: IMPlatform,
conversationId: string,
): StoredConversationReplyRoute | null {
const normalizedConversationId = conversationId.trim();
if (!normalizedConversationId) {
return null;
}
return this.getConfigValue<StoredConversationReplyRoute>(
`conversation_reply_route:${platform}:${normalizedConversationId}`,
) ?? null;
}
setConversationReplyRoute(
platform: IMPlatform,
conversationId: string,
route: StoredConversationReplyRoute,
): void {
const normalizedConversationId = conversationId.trim();
if (!normalizedConversationId) {
return;
}
this.setConfigValue(`conversation_reply_route:${platform}:${normalizedConversationId}`, route);
}
// ==================== Session Mapping Operations ====================
/**
@@ -550,6 +581,25 @@ export class IMStore {
};
}
/**
* Find the IM mapping that owns a given cowork session ID.
*/
getSessionMappingByCoworkSessionId(coworkSessionId: string): IMSessionMapping | null {
const result = this.db.exec(
'SELECT im_conversation_id, platform, cowork_session_id, created_at, last_active_at FROM im_session_mappings WHERE cowork_session_id = ? LIMIT 1',
[coworkSessionId]
);
if (!result[0]?.values[0]) return null;
const row = result[0].values[0];
return {
imConversationId: row[0] as string,
platform: row[1] as IMPlatform,
coworkSessionId: row[2] as string,
createdAt: row[3] as number,
lastActiveAt: row[4] as number,
};
}
/**
* Create a new session mapping
*/
@@ -33,6 +33,7 @@ const BRIDGE_MAX_MESSAGES = 20;
const BRIDGE_MAX_MESSAGE_CHARS = 1200;
const GATEWAY_READY_TIMEOUT_MS = 15_000;
const FINAL_HISTORY_SYNC_LIMIT = 50;
const CHANNEL_SESSION_DISCOVERY_LIMIT = 200;
type GatewayEventFrame = {
event: string;
@@ -134,10 +135,22 @@ type PendingApprovalEntry = {
sessionId: string;
};
type ChannelHistorySyncEntry = {
role: 'user' | 'assistant';
text: string;
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
};
const isSameChannelHistoryEntry = (
left: ChannelHistorySyncEntry,
right: ChannelHistorySyncEntry,
): boolean => {
return left.role === right.role && left.text === right.text;
};
const truncate = (value: string, maxChars: number): string => {
if (value.length <= maxChars) return value;
return `${value.slice(0, maxChars)}...`;
@@ -662,7 +675,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
return;
}
try {
const params = { activeMinutes: 60, limit: 50 };
const params = { activeMinutes: 60, limit: CHANNEL_SESSION_DISCOVERY_LIMIT };
console.log('[ChannelSync] pollChannelSessions: calling sessions.list with', JSON.stringify(params));
const result = await this.gatewayClient.request('sessions.list', params);
const sessions = (result as Record<string, unknown>)?.sessions;
@@ -2203,6 +2216,18 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
}
}
/**
* Channel history prefetch/full-sync intentionally skips historical system entries.
* Seed the raw gateway history cursor so those older reminders are not replayed
* under the next assistant reply during final-history sync.
*/
private markGatewayHistoryWindowConsumed(sessionId: string, historyMessages: unknown[]): void {
if (historyMessages.length === 0) {
return;
}
this.gatewayHistoryCountBySession.set(sessionId, historyMessages.length);
}
private async syncFinalAssistantWithHistory(sessionId: string, turn: ActiveTurn): Promise<void> {
console.log('[Debug:syncFinal] start — sessionId:', sessionId, 'sessionKey:', turn.sessionKey);
const client = this.gatewayClient;
@@ -2355,25 +2380,12 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
}
}
/**
* Sync user messages from gateway chat.history that haven't been added to the local store yet.
* Used for channel-originated sessions (e.g. Telegram) where user messages arrive via the
* gateway rather than the LobsterAI UI.
*
* Called at the start of a new turn (via prefetchChannelUserMessages) so that user messages
* appear before the assistant's streaming response. Both chat and agent events are buffered
* during prefetch, so the replay order matches direct cowork sessions.
*
* Uses position-based matching: compares history entries with local messages sequentially
* to avoid false dedup of identical-content messages (e.g. two "ok" messages in a row).
*/
private syncChannelUserMessages(sessionId: string, historyMessages: unknown[], latestOnly = false, isDiscord = false, isQQ = false): void {
console.log('[Debug:syncChannelUserMessages] sessionId:', sessionId, 'historyMessages:', historyMessages.length, 'latestOnly:', latestOnly, 'isQQ:', isQQ);
const session = this.store.getSession(sessionId);
// Collect user + assistant messages from history in chronological order
type MsgEntry = { role: 'user' | 'assistant'; text: string };
const historyEntries: MsgEntry[] = [];
private collectChannelHistoryEntries(
historyMessages: unknown[],
isDiscord: boolean,
isQQ: boolean,
): ChannelHistorySyncEntry[] {
const historyEntries: ChannelHistorySyncEntry[] = [];
for (const message of historyMessages) {
if (!isRecord(message)) continue;
const role = typeof message.role === 'string' ? message.role.trim().toLowerCase() : '';
@@ -2385,6 +2397,127 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
historyEntries.push({ role: role as 'user' | 'assistant', text });
}
}
return historyEntries;
}
private collectLocalChannelEntries(sessionId: string): ChannelHistorySyncEntry[] {
const session = this.store.getSession(sessionId);
if (!session) return [];
const localEntries: ChannelHistorySyncEntry[] = [];
for (const msg of session.messages) {
if (msg.type !== 'user' && msg.type !== 'assistant') continue;
const text = msg.content.trim();
if (!text) continue;
localEntries.push({ role: msg.type, text });
}
return localEntries;
}
private computeChannelHistoryFirstNewIndex(
localEntries: ChannelHistorySyncEntry[],
historyEntries: ChannelHistorySyncEntry[],
cursor: number,
): { firstNewIdx: number; strategy: string } {
if (localEntries.length === 0) {
return { firstNewIdx: 0, strategy: 'empty-local' };
}
// `chat.history` is byte-bounded in OpenClaw, so the returned window can slide
// long before it reaches our requested count. Match the local tail against the
// current history prefix to find the continuation point without trusting length.
const maxOverlap = Math.min(localEntries.length, historyEntries.length);
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
let matched = true;
for (let idx = 0; idx < overlap; idx += 1) {
const localEntry = localEntries[localEntries.length - overlap + idx];
const historyEntry = historyEntries[idx];
if (!isSameChannelHistoryEntry(localEntry, historyEntry)) {
matched = false;
break;
}
}
if (matched) {
return { firstNewIdx: overlap, strategy: 'tail-overlap' };
}
}
let lastLocalUserIdx = -1;
for (let idx = localEntries.length - 1; idx >= 0; idx -= 1) {
if (localEntries[idx].role === 'user') {
lastLocalUserIdx = idx;
break;
}
}
if (lastLocalUserIdx >= 0) {
const lastLocalUser = localEntries[lastLocalUserIdx];
let prevLocalUserText: string | undefined;
for (let idx = lastLocalUserIdx - 1; idx >= 0; idx -= 1) {
if (localEntries[idx].role === 'user') {
prevLocalUserText = localEntries[idx].text;
break;
}
}
for (let idx = historyEntries.length - 1; idx >= 0; idx -= 1) {
if (historyEntries[idx].role !== 'user' || historyEntries[idx].text !== lastLocalUser.text) {
continue;
}
if (prevLocalUserText !== undefined && idx > 0) {
let prevHistUserText: string | undefined;
for (let histIdx = idx - 1; histIdx >= 0; histIdx -= 1) {
if (historyEntries[histIdx].role === 'user') {
prevHistUserText = historyEntries[histIdx].text;
break;
}
}
if (prevHistUserText !== prevLocalUserText) {
continue;
}
}
return { firstNewIdx: idx + 1, strategy: 'last-user-anchor' };
}
}
let localIdx = 0;
let forwardFirstNewIdx = 0;
for (let idx = 0; idx < historyEntries.length; idx += 1) {
if (localIdx < localEntries.length && isSameChannelHistoryEntry(historyEntries[idx], localEntries[localIdx])) {
localIdx += 1;
forwardFirstNewIdx = idx + 1;
}
}
if (forwardFirstNewIdx > 0) {
return { firstNewIdx: forwardFirstNewIdx, strategy: 'forward-match' };
}
if (historyEntries.length < cursor) {
return { firstNewIdx: 0, strategy: 'history-rewrite' };
}
return {
firstNewIdx: Math.min(cursor, historyEntries.length),
strategy: 'cursor-fallback',
};
}
/**
* Sync user messages from gateway chat.history that haven't been added to the local store yet.
* Used for channel-originated sessions (e.g. Telegram) where user messages arrive via the
* gateway rather than the LobsterAI UI.
*
* Called at the start of a new turn (via prefetchChannelUserMessages) so that user messages
* appear before the assistant's streaming response. Both chat and agent events are buffered
* during prefetch, so the replay order matches direct cowork sessions.
*
* Reconciles against the local tail instead of trusting history length/cursor alone,
* because OpenClaw's `chat.history` window can slide due to byte limits well before
* the requested message count is reached.
*/
private syncChannelUserMessages(sessionId: string, historyMessages: unknown[], latestOnly = false, isDiscord = false, isQQ = false): void {
console.log('[Debug:syncChannelUserMessages] sessionId:', sessionId, 'historyMessages:', historyMessages.length, 'latestOnly:', latestOnly, 'isQQ:', isQQ);
const historyEntries = this.collectChannelHistoryEntries(historyMessages, isDiscord, isQQ);
const cursor = this.channelSyncCursor.get(sessionId) ?? 0;
console.log('[Debug:syncChannelUserMessages] cursor:', cursor, 'history entries:', historyEntries.length);
@@ -2394,7 +2527,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
// Advance cursor to end so subsequent syncs don't replay old history.
if (latestOnly) {
if (historyEntries.length > 0) {
const lastUser = [...historyEntries].reverse().find(e => e.role === 'user');
const lastUser = [...historyEntries].reverse().find((entry) => entry.role === 'user');
if (lastUser) {
const userMessage = this.store.addMessage(sessionId, {
type: 'user',
@@ -2409,124 +2542,18 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
return;
}
// Determine firstNewIdx: where in historyEntries new (unsynced) messages start.
// Use RAW message count (historyMessages.length) to detect sliding window, NOT the
// filtered historyEntries count or cursor. When the gateway returns FINAL_HISTORY_SYNC_LIMIT
// raw messages, the window is at capacity and sliding — even if filtered entries are fewer
// due to toolCall/toolResult entries being stripped (e.g. raw=50, filtered=48, cursor=48).
let firstNewIdx: number;
if (historyMessages.length >= FINAL_HISTORY_SYNC_LIMIT) {
// Sliding window: gateway returned a full window, position-based cursor is unreliable.
// Find the continuation point by matching the last local message in history.
console.log('[Debug:syncChannelUserMessages] history at capacity (raw:', historyMessages.length, ', filtered:', historyEntries.length, ', cursor:', cursor, '), using content matching');
const localEntries: MsgEntry[] = [];
if (session) {
for (const msg of session.messages) {
if (msg.type === 'user' || msg.type === 'assistant') {
localEntries.push({ role: msg.type, text: msg.content.trim() });
}
}
}
if (localEntries.length > 0) {
const lastLocal = localEntries[localEntries.length - 1];
let matchPos = -1;
for (let i = historyEntries.length - 1; i >= 0; i--) {
if (historyEntries[i].role === lastLocal.role
&& historyEntries[i].text === lastLocal.text) {
// Double-check: verify the preceding entry also matches to avoid
// false positives from repeated identical messages (e.g. "再来一个").
if (localEntries.length >= 2 && i > 0) {
const secondLastLocal = localEntries[localEntries.length - 2];
if (historyEntries[i - 1].role === secondLastLocal.role
&& historyEntries[i - 1].text === secondLastLocal.text) {
matchPos = i;
break;
}
// Preceding entry didn't match — might be a duplicate, keep searching.
continue;
}
matchPos = i;
break;
}
}
// Fallback: if the last local entry is an assistant message and matching failed,
// the stored text may be a segment (from resolveAssistantSegmentText) that differs
// from the full text in gateway history. Retry with the last USER message instead,
// which is always stored verbatim and matches gateway history exactly.
if (matchPos < 0 && lastLocal.role === 'assistant') {
let lastLocalUserIdx = -1;
for (let j = localEntries.length - 1; j >= 0; j--) {
if (localEntries[j].role === 'user') {
lastLocalUserIdx = j;
break;
}
}
if (lastLocalUserIdx >= 0) {
const lastLocalUser = localEntries[lastLocalUserIdx];
// Find preceding user message in local for double-verification
let prevLocalUserText: string | undefined;
for (let j = lastLocalUserIdx - 1; j >= 0; j--) {
if (localEntries[j].role === 'user') {
prevLocalUserText = localEntries[j].text;
break;
}
}
console.log('[Debug:syncChannelUserMessages] assistant match failed, retrying with last user entry');
for (let i = historyEntries.length - 1; i >= 0; i--) {
if (historyEntries[i].role === 'user'
&& historyEntries[i].text === lastLocalUser.text) {
// Double-check: verify the preceding user message also matches
if (prevLocalUserText !== undefined && i > 0) {
let prevHistUserText: string | undefined;
for (let k = i - 1; k >= 0; k--) {
if (historyEntries[k].role === 'user') {
prevHistUserText = historyEntries[k].text;
break;
}
}
if (prevHistUserText !== prevLocalUserText) {
continue; // Double-check failed, keep searching
}
}
matchPos = i;
break;
}
}
}
}
firstNewIdx = matchPos >= 0 ? matchPos + 1 : historyEntries.length;
console.log('[Debug:syncChannelUserMessages] content match result: matchPos:', matchPos, 'firstNewIdx:', firstNewIdx);
} else {
firstNewIdx = 0; // No local messages, sync everything
}
} else if (historyEntries.length < cursor) {
// Safety: gateway returned fewer entries than cursor (session truncated/rebuilt).
// Only reached when raw history < FINAL_HISTORY_SYNC_LIMIT (not a sliding window).
console.warn('[Debug:syncChannelUserMessages] history shrank (cursor:', cursor, 'entries:', historyEntries.length, '), falling back to text matching');
const localEntries: MsgEntry[] = [];
if (session) {
for (const msg of session.messages) {
if (msg.type === 'user' || msg.type === 'assistant') {
localEntries.push({ role: msg.type, text: msg.content.trim() });
}
}
}
let localIdx = 0;
firstNewIdx = 0;
for (let i = 0; i < historyEntries.length; i++) {
if (localIdx < localEntries.length
&& historyEntries[i].role === localEntries[localIdx].role
&& historyEntries[i].text === localEntries[localIdx].text) {
localIdx++;
firstNewIdx = i + 1;
}
}
} else {
firstNewIdx = cursor;
}
const localEntries = this.collectLocalChannelEntries(sessionId);
const { firstNewIdx, strategy } = this.computeChannelHistoryFirstNewIndex(localEntries, historyEntries, cursor);
console.log(
'[Debug:syncChannelUserMessages] continuation strategy:',
strategy,
'firstNewIdx:',
firstNewIdx,
'local entries:',
localEntries.length,
'history entries:',
historyEntries.length,
);
// Append messages from firstNewIdx onwards.
// Only sync user messages here — assistant messages are already added by the
@@ -2578,6 +2605,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
console.log('[ChannelSync] syncFullChannelHistory: no messages in history');
return;
}
this.markGatewayHistoryWindowConsumed(sessionId, history.messages);
const session = this.store.getSession(sessionId);
// Build ordered list of existing local messages for position-based matching
@@ -2927,6 +2955,7 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
console.log('[Debug:prefetch] chat.history returned', msgCount, 'messages (attempt', attempt, ')');
if (Array.isArray(history?.messages) && history.messages.length > 0) {
this.markGatewayHistoryWindowConsumed(sessionId, history.messages);
const latestOnly = this.reCreatedChannelSessionIds.has(sessionId);
const beforeCount = this.getUserMessageCount(sessionId);
this.syncChannelUserMessages(sessionId, history.messages, latestOnly, sessionKey.includes(':discord:'), sessionKey.includes(':qqbot:'));
@@ -3061,6 +3090,36 @@ export class OpenClawRuntimeAdapter extends EventEmitter implements CoworkRuntim
return this.gatewayClient;
}
getSessionKeysForSession(sessionId: string): string[] {
const normalizedSessionId = sessionId.trim();
if (!normalizedSessionId) {
return [];
}
const keys: string[] = [];
for (const [key, mappedSessionId] of this.sessionIdBySessionKey.entries()) {
if (mappedSessionId === normalizedSessionId) {
keys.push(key);
}
}
const managedKey = this.toSessionKey(normalizedSessionId);
if (!keys.includes(managedKey)) {
keys.push(managedKey);
}
keys.sort((left, right) => {
const leftManaged = isManagedSessionKey(left);
const rightManaged = isManagedSessionKey(right);
if (leftManaged !== rightManaged) {
return leftManaged ? 1 : -1;
}
return left.localeCompare(right);
});
return keys;
}
/**
* Ensure the gateway client is connected and ready.
* Resolves when the WebSocket connection is established and authenticated.
+28
View File
@@ -43,6 +43,8 @@ const MANAGED_OWNER_ALLOW_FROM = [
'gateway-client',
];
const MANAGED_TOOL_DENY = ['web_search'] as const;
const MANAGED_SKILL_ENTRY_OVERRIDES: Record<string, { enabled: boolean }> = {
// QQ plugin ships a legacy reminder skill that steers the model toward a
// channel-specific cron wrapper/subagent flow. Hide that path so native IM
@@ -62,6 +64,19 @@ const DISABLED_MANAGED_SKILL_NAMES = Object.entries(MANAGED_SKILL_ENTRY_OVERRIDE
.filter(([, value]) => value.enabled === false)
.map(([name]) => name);
const MANAGED_WEB_SEARCH_POLICY_PROMPT = [
'## Web Search',
'',
'Built-in `web_search` is disabled in this workspace. Do not ask for or rely on the Brave Search API.',
'',
'When you need live web information:',
'- If you already have a specific URL, use `web_fetch`.',
'- If you need search discovery, dynamic pages, or interactive browsing, use the built-in `browser` tool.',
'- Only use the LobsterAI `web-search` skill when local command execution is available. Native channel sessions may deny `exec`, so prefer `browser` or `web_fetch` there.',
'',
'Do not claim you searched the web unless you actually used `browser`, `web_fetch`, or the LobsterAI `web-search` skill.',
].join('\n');
const sessionSnapshotContainsDisabledManagedSkill = (entry: Record<string, unknown>): boolean => {
const skillsSnapshot = entry.skillsSnapshot;
if (!skillsSnapshot || typeof skillsSnapshot !== 'object') {
@@ -401,6 +416,17 @@ export class OpenClawConfigSync {
commands: {
ownerAllowFrom: MANAGED_OWNER_ALLOW_FROM,
},
tools: {
deny: [...MANAGED_TOOL_DENY],
web: {
search: {
enabled: false,
},
},
},
browser: {
enabled: true,
},
skills: {
entries: MANAGED_SKILL_ENTRY_OVERRIDES,
},
@@ -804,6 +830,8 @@ export class OpenClawConfigSync {
sections.push(skillsPrompt);
}
sections.push(MANAGED_WEB_SEARCH_POLICY_PROMPT);
// Keep scheduled-task policy after skills so native channel sessions
// treat it as the final app-managed override for reminder handling.
const scheduledTaskPrompt = buildScheduledTaskEnginePrompt('openclaw').replaceAll(MARKER, '');
+7 -56
View File
@@ -1,3 +1,8 @@
import {
parseScheduledReminderPrompt,
parseSimpleScheduledReminderText,
} from '../../common/scheduledReminderText';
type GatewayHistoryRole = 'user' | 'assistant' | 'system';
export interface GatewayHistoryEntry {
@@ -5,11 +10,6 @@ export interface GatewayHistoryEntry {
text: string;
}
const SCHEDULED_REMINDER_PREFIX = 'A scheduled reminder has been triggered. The reminder content is:';
const SCHEDULED_REMINDER_INTERNAL_INSTRUCTION = 'Handle this reminder internally. Do not relay it to the user unless explicitly requested.';
const SCHEDULED_REMINDER_RELAY_INSTRUCTION = 'Please relay this reminder to the user in a helpful and friendly way.';
const CURRENT_TIME_PREFIX = 'Current time:';
const isRecord = (value: unknown): value is Record<string, unknown> => {
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
};
@@ -44,62 +44,13 @@ export const extractGatewayMessageText = (message: unknown): string => {
return '';
};
type ScheduledReminderPrompt = {
reminderText: string;
currentTime?: string;
};
const parseScheduledReminderPrompt = (text: string): ScheduledReminderPrompt | null => {
const trimmed = text.trim();
if (!trimmed.startsWith(SCHEDULED_REMINDER_PREFIX)) {
return null;
}
let remainder = trimmed.slice(SCHEDULED_REMINDER_PREFIX.length).trim();
let currentTime: string | undefined;
const currentTimeIndex = remainder.lastIndexOf(CURRENT_TIME_PREFIX);
if (currentTimeIndex >= 0) {
currentTime = remainder.slice(currentTimeIndex + CURRENT_TIME_PREFIX.length).trim() || undefined;
remainder = remainder.slice(0, currentTimeIndex).trim();
}
if (remainder.endsWith(SCHEDULED_REMINDER_INTERNAL_INSTRUCTION)) {
remainder = remainder.slice(0, -SCHEDULED_REMINDER_INTERNAL_INSTRUCTION.length).trim();
} else if (remainder.endsWith(SCHEDULED_REMINDER_RELAY_INSTRUCTION)) {
remainder = remainder.slice(0, -SCHEDULED_REMINDER_RELAY_INSTRUCTION.length).trim();
}
if (!remainder) {
return null;
}
return {
reminderText: remainder,
...(currentTime ? { currentTime } : {}),
};
};
export const buildScheduledReminderSystemMessage = (text: string): string | null => {
const parsed = parseScheduledReminderPrompt(text);
if (!parsed) {
return null;
return parseSimpleScheduledReminderText(text)?.reminderText ?? null;
}
const lines = [
`System: ${parsed.currentTime ? `[${parsed.currentTime}] ` : ''}${parsed.reminderText}`,
'',
SCHEDULED_REMINDER_PREFIX,
'',
parsed.reminderText,
'',
SCHEDULED_REMINDER_RELAY_INSTRUCTION,
];
if (parsed.currentTime) {
lines.push(`${CURRENT_TIME_PREFIX} ${parsed.currentTime}`);
}
return lines.join('\n');
return parsed.reminderText;
};
export const extractGatewayHistoryEntry = (message: unknown): GatewayHistoryEntry | null => {
+162 -2
View File
@@ -7,6 +7,103 @@ const OWNER_ONLY_FALLBACK_PATTERN =
/(const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set\(\[\s*\n\s*"whatsapp_login",\s*\n\s*)"cron",\s*\n(\s*"gateway"\s*\n\s*\]\);)/;
const WECOM_EXEC_DENY_PATTERN =
/const TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\](?:,\s*wecom: \["exec", "process"\])? \};/;
const CRON_DELIVERY_INFERENCE_OLD = `function inferDeliveryFromSessionKey(agentSessionKey) {
\tconst rawSessionKey = agentSessionKey?.trim();
\tif (!rawSessionKey) return null;
\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));
\tif (!parsed || !parsed.rest) return null;
\tconst parts = parsed.rest.split(":").filter(Boolean);
\tif (parts.length === 0) return null;
\tconst head = parts[0]?.trim().toLowerCase();
\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;
\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");
\tif (markerIndex === -1) return null;
\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();
\tif (!peerId) return null;
\tlet channel;
\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();
\tconst delivery = {
\t\tmode: "announce",
\t\tto: peerId
\t};
\tif (channel) delivery.channel = channel;
\treturn delivery;
}`;
const CRON_DELIVERY_INFERENCE_NEW = `function inferDeliveryFromSessionKey(agentSessionKey) {
\tconst rawSessionKey = agentSessionKey?.trim();
\tif (!rawSessionKey) return null;
\tconst { deliveryContext, threadId } = extractDeliveryInfo(rawSessionKey);
\tconst persistedDelivery = normalizeDeliveryContext(deliveryContext);
\tif (persistedDelivery?.channel && persistedDelivery?.to) {
\t\tconst delivery = {
\t\t\tmode: "announce",
\t\t\tchannel: persistedDelivery.channel,
\t\t\tto: persistedDelivery.to
\t\t};
\t\tif (persistedDelivery.accountId) delivery.accountId = persistedDelivery.accountId;
\t\tif (persistedDelivery.threadId != null) delivery.threadId = persistedDelivery.threadId;
\t\telse if (threadId != null) delivery.threadId = threadId;
\t\treturn delivery;
\t}
\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));
\tif (!parsed || !parsed.rest) return null;
\tconst parts = parsed.rest.split(":").filter(Boolean);
\tif (parts.length === 0) return null;
\tconst head = parts[0]?.trim().toLowerCase();
\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;
\tif (head === "dingtalk-connector") {
\t\tconst accountId = parts[1]?.trim();
\t\tconst senderId = parts[2]?.trim();
\t\tif (accountId && senderId) {
\t\t\tconst delivery = {
\t\t\t\tmode: "announce",
\t\t\t\tchannel: "dingtalk-connector",
\t\t\t\tto: \`user:\${senderId}\`,
\t\t\t\taccountId
\t\t\t};
\t\t\tif (threadId != null) delivery.threadId = threadId;
\t\t\treturn delivery;
\t\t}
\t}
\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");
\tif (markerIndex === -1) return null;
\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();
\tif (!peerId) return null;
\tlet channel;
\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();
\tconst delivery = {
\t\tmode: "announce",
\t\tto: peerId
\t};
\tif (channel) delivery.channel = channel;
\tif (threadId != null) delivery.threadId = threadId;
\treturn delivery;
}`;
const CRON_REMINDER_PROMPT_OLD = [
'function buildCronEventPrompt(pendingEvents, opts) {',
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
'\tconst eventText = pendingEvents.join("\\n").trim();',
'\tif (!eventText) {',
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
'\t}',
'\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";',
'\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";',
'}',
].join('\n');
const CRON_REMINDER_PROMPT_NEW = [
'function buildCronEventPrompt(pendingEvents, opts) {',
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
'\tconst eventText = pendingEvents.join("\\n").trim();',
'\tif (!eventText) {',
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
'\t}',
'\treturn eventText;',
'}',
].join('\n');
const CRON_REMINDER_CURRENT_TIME_BODY_OLD = 'Body: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),';
const CRON_REMINDER_CURRENT_TIME_BODY_NEW = 'Body: hasCronEvents ? prompt : appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),';
const walkJsFiles = (dirPath: string, files: string[]): void => {
let entries: fs.Dirent[];
@@ -69,6 +166,57 @@ export const patchWecomMessageProviderExecDeny = (
};
};
export const patchCronSessionDeliveryInference = (
source: string,
): { changed: boolean; content: string } => {
if (
!source.includes('function inferDeliveryFromSessionKey(agentSessionKey) {') ||
!source.includes('const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));')
) {
return { changed: false, content: source };
}
const next = source.replace(CRON_DELIVERY_INFERENCE_OLD, CRON_DELIVERY_INFERENCE_NEW);
return {
changed: next !== source,
content: next,
};
};
export const patchCronReminderPromptEnvelope = (
source: string,
): { changed: boolean; content: string } => {
if (
!source.includes('function buildCronEventPrompt(') ||
!source.includes('A scheduled reminder has been triggered. The reminder content is:')
) {
return { changed: false, content: source };
}
const next = source.replace(CRON_REMINDER_PROMPT_OLD, CRON_REMINDER_PROMPT_NEW);
return {
changed: next !== source,
content: next,
};
};
export const patchCronReminderCurrentTimeSuffix = (
source: string,
): { changed: boolean; content: string } => {
if (
!source.includes(CRON_REMINDER_CURRENT_TIME_BODY_OLD) ||
!source.includes('hasCronEvents')
) {
return { changed: false, content: source };
}
const next = source.replace(
CRON_REMINDER_CURRENT_TIME_BODY_OLD,
CRON_REMINDER_CURRENT_TIME_BODY_NEW,
);
return {
changed: next !== source,
content: next,
};
};
export const applyBundledOpenClawRuntimeHotfixes = (
runtimeRoot: string,
): { changed: boolean; patchedFiles: string[]; errors: string[] } => {
@@ -91,10 +239,22 @@ export const applyBundledOpenClawRuntimeHotfixes = (
const cronOwnerPatch = patchCronToolOwnerOnly(source);
const fallbackPatch = patchCronOwnerFallback(cronOwnerPatch.content);
const wecomExecPatch = patchWecomMessageProviderExecDeny(fallbackPatch.content);
if (!cronOwnerPatch.changed && !fallbackPatch.changed && !wecomExecPatch.changed) continue;
const cronDeliveryPatch = patchCronSessionDeliveryInference(wecomExecPatch.content);
const cronReminderPromptPatch = patchCronReminderPromptEnvelope(cronDeliveryPatch.content);
const cronReminderTimePatch = patchCronReminderCurrentTimeSuffix(cronReminderPromptPatch.content);
if (
!cronOwnerPatch.changed &&
!fallbackPatch.changed &&
!wecomExecPatch.changed &&
!cronDeliveryPatch.changed &&
!cronReminderPromptPatch.changed &&
!cronReminderTimePatch.changed
) {
continue;
}
try {
fs.writeFileSync(filePath, wecomExecPatch.content, 'utf8');
fs.writeFileSync(filePath, cronReminderTimePatch.content, 'utf8');
patchedFiles.push(filePath);
} catch (error) {
errors.push(`${filePath}: ${error instanceof Error ? error.message : String(error)}`);
+19 -1
View File
@@ -1117,7 +1117,25 @@ const getIMGatewayManager = () => {
await openClawRuntimeAdapter.connectGatewayIfNeeded();
}
},
createScheduledTask: async ({ sessionId, request }) => {
getOpenClawGatewayClient: () => openClawRuntimeAdapter?.getGatewayClient() ?? null,
ensureOpenClawGatewayReady: async () => {
if (!openClawRuntimeAdapter) {
throw new Error('OpenClaw runtime adapter not initialized.');
}
await openClawRuntimeAdapter.ensureReady();
await openClawRuntimeAdapter.connectGatewayIfNeeded();
},
getOpenClawSessionKeysForCoworkSession: (sessionId: string) => {
return openClawRuntimeAdapter?.getSessionKeysForSession(sessionId) ?? [];
},
createScheduledTask: async ({ sessionId, message, request }) => {
if (message.platform === 'dingtalk') {
await getIMGatewayManager().primeConversationReplyRoute(
message.platform,
message.conversationId,
sessionId,
);
}
const task = await getCronJobService().addJob({
name: request.taskName,
description: '',
@@ -24,6 +24,7 @@ import PencilSquareIcon from '../icons/PencilSquareIcon';
import TrashIcon from '../icons/TrashIcon';
import WindowTitleBar from '../window/WindowTitleBar';
import { getCompactFolderName } from '../../utils/path';
import { getScheduledReminderDisplayText } from '../../../common/scheduledReminderText';
interface CoworkSessionDetailProps {
onManageSkills?: () => void;
@@ -1136,7 +1137,8 @@ export const AssistantTurnBlock: React.FC<{
const rawContent = hasText(message.content)
? message.content
: (typeof message.metadata?.error === 'string' ? message.metadata.error : '');
const content = mapDisplayText ? mapDisplayText(rawContent) : rawContent;
const normalizedContent = getScheduledReminderDisplayText(rawContent) ?? rawContent;
const content = mapDisplayText ? mapDisplayText(normalizedContent) : normalizedContent;
if (!content.trim()) return null;
return (
+7
View File
@@ -35,6 +35,7 @@ class CoworkService {
private openClawStatus: OpenClawEngineStatus | null = null;
private openClawStatusListeners = new Set<(status: OpenClawEngineStatus) => void>();
private openClawEngineListenerAttached = false;
private latestLoadSessionsRequestId = 0;
private latestLoadSessionRequestId = 0;
async init(): Promise<void> {
@@ -165,8 +166,14 @@ class CoworkService {
}
async loadSessions(): Promise<void> {
const requestId = ++this.latestLoadSessionsRequestId;
const result = await window.electron?.cowork?.listSessions();
if (result?.success && result.sessions) {
// High-frequency IM traffic can trigger overlapping list refreshes.
// Ignore stale responses so an older snapshot does not hide newer sessions.
if (requestId !== this.latestLoadSessionsRequestId) {
return;
}
store.dispatch(setSessions(result.sessions));
}
}
+53 -1
View File
@@ -106,6 +106,10 @@ class FakeIMStore {
)) || null;
}
getSessionMappingByCoworkSessionId(coworkSessionId) {
return this.mappings.find((entry) => entry.coworkSessionId === coworkSessionId) || null;
}
createSessionMapping(imConversationId, platform, coworkSessionId) {
const mapping = {
imConversationId,
@@ -245,7 +249,7 @@ test('async reminder turns on IM-created sessions relay back to the original IM
runtime.emit('message', session.id, {
id: 'system-1',
type: 'system',
content: 'System: [Sunday, March 15th, 2026 — 4:30 PM] ⏰ 提醒:喝水',
content: '⏰ 提醒:喝水',
timestamp: Date.now(),
metadata: {},
});
@@ -271,6 +275,54 @@ test('async reminder turns on IM-created sessions relay back to the original IM
handler.destroy();
});
test('async reminder turns on channel-synced sessions are tracked lazily and relay back', async () => {
const runtime = new FakeRuntime();
const coworkStore = new FakeCoworkStore();
const imStore = new FakeIMStore();
const relayedReplies = [];
const session = coworkStore.createSession('IM-dingtalk', process.cwd(), '', 'auto');
imStore.createSessionMapping('default:user-42', 'dingtalk', session.id);
const handler = new IMCoworkHandler({
coworkRuntime: runtime,
coworkStore,
imStore,
sendAsyncReply: async (platform, conversationId, text) => {
relayedReplies.push({ platform, conversationId, text });
return true;
},
});
runtime.emit('message', session.id, {
id: 'system-1',
type: 'system',
content: '⏰ 提醒:开会',
timestamp: Date.now(),
metadata: {},
});
runtime.emit('message', session.id, {
id: 'assistant-1',
type: 'assistant',
content: '时间到了,记得开会。',
timestamp: Date.now(),
metadata: {},
});
runtime.emit('complete', session.id, null);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(relayedReplies, [
{
platform: 'dingtalk',
conversationId: 'default:user-42',
text: '时间到了,记得开会。',
},
]);
handler.destroy();
});
test('falls back to normal agent execution when detector does not recognize a scheduled task', async () => {
const runtime = new FakeRuntime();
const coworkStore = new FakeCoworkStore();
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const {
buildDingTalkSessionKeyCandidates,
buildDingTalkSendParamsFromRoute,
extractOpenClawDeliveryRoute,
resolveOpenClawDeliveryRouteForSessionKeys,
resolveManagedSessionDeliveryRoute,
} = require('../dist-electron/main/im/imDeliveryRoute.js');
test('managed session delivery route prefers deliveryContext over legacy last route fields', () => {
const resolved = resolveManagedSessionDeliveryRoute('session-1', [
{
key: 'agent:main:lobsterai:session-1',
lastChannel: 'dingtalk-connector',
lastTo: 'user:legacy-user',
lastAccountId: 'legacy-account',
deliveryContext: {
channel: 'dingtalk-connector',
to: 'group:cid-123',
accountId: '__default__',
},
},
]);
assert.deepEqual(resolved, {
sessionKey: 'agent:main:lobsterai:session-1',
route: {
channel: 'dingtalk-connector',
to: 'group:cid-123',
accountId: '__default__',
},
});
assert.deepEqual(buildDingTalkSendParamsFromRoute(resolved.route), {
target: 'group:cid-123',
accountId: '__default__',
});
});
test('managed session delivery route falls back to last route fields', () => {
const resolved = resolveManagedSessionDeliveryRoute('session-2', [
{
key: 'agent:main:lobsterai:session-2',
lastChannel: 'dingtalk-connector',
lastTo: 'user:staff-42',
lastAccountId: 'acct-1',
},
]);
assert.deepEqual(resolved, {
sessionKey: 'agent:main:lobsterai:session-2',
route: {
channel: 'dingtalk-connector',
to: 'user:staff-42',
accountId: 'acct-1',
},
});
});
test('route lookup can match DingTalk channel session keys discovered outside the managed session namespace', () => {
const candidateSessionKeys = [
...buildDingTalkSessionKeyCandidates('__default__:2459325231940374'),
'agent:main:lobsterai:session-3',
];
const resolved = resolveOpenClawDeliveryRouteForSessionKeys(candidateSessionKeys, [
{
key: 'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374',
deliveryContext: {
channel: 'dingtalk-connector',
to: 'group:cid-42',
accountId: '__default__',
},
},
]);
assert.deepEqual(resolved, {
sessionKey: 'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374',
route: {
channel: 'dingtalk-connector',
to: 'group:cid-42',
accountId: '__default__',
},
});
});
test('delivery route extraction ignores incomplete session rows and non-dingtalk channels', () => {
assert.equal(extractOpenClawDeliveryRoute({ key: 'agent:main:lobsterai:session-3' }), null);
assert.equal(buildDingTalkSendParamsFromRoute({
channel: 'telegram',
to: 'chat:123',
accountId: 'default',
}), null);
});
+14
View File
@@ -49,8 +49,22 @@ test('identifies reminder system turns for async IM delivery', () => {
{ type: 'assistant', content: '普通回复' },
]), false);
assert.equal(isReminderSystemTurn([
{ type: 'system', content: '⏰ 提醒:喝饮料' },
{ type: 'assistant', content: '该喝饮料啦!' },
]), true);
});
test('keeps recognizing legacy reminder system messages during transition', () => {
assert.equal(isReminderSystemTurn([
{ type: 'system', content: 'System: [Sunday, March 15th, 2026 — 4:30 PM] ⏰ 提醒:喝饮料' },
{ type: 'assistant', content: '该喝饮料啦!' },
]), true);
});
test('recognizes plain reminder text turns during runtime hotfix rollout', () => {
assert.equal(isReminderSystemTurn([
{ type: 'user', content: '⏰ 提醒:该去钉钉打卡啦!别忘了打卡哦~' },
{ type: 'assistant', content: '⏰ 时间到啦,该去打卡了。' },
]), true);
});
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const { IMStore } = require('../dist-electron/main/im/imStore.js');
class FakeDb {
constructor() {
this.imConfig = new Map();
}
run(sql, params = []) {
if (sql.includes('INSERT INTO im_config')) {
this.imConfig.set(String(params[0]), String(params[1]));
return;
}
if (sql.includes('INSERT OR REPLACE INTO im_config')) {
this.imConfig.set(String(params[0]), String(params[1]));
return;
}
if (sql.includes('DELETE FROM im_config WHERE key = ?')) {
this.imConfig.delete(String(params[0]));
return;
}
if (sql.includes('DELETE FROM im_config')) {
this.imConfig.clear();
}
}
exec(sql, params = []) {
if (sql.includes('SELECT value FROM im_config WHERE key = ?')) {
const value = this.imConfig.get(String(params[0]));
return value === undefined ? [] : [{ values: [[value]] }];
}
return [];
}
}
test('IMStore persists conversation reply routes by platform and conversation ID', () => {
const db = new FakeDb();
let saveCount = 0;
const store = new IMStore(db, () => {
saveCount += 1;
});
assert.equal(store.getConversationReplyRoute('dingtalk', '__default__:conv-1'), null);
store.setConversationReplyRoute('dingtalk', '__default__:conv-1', {
channel: 'dingtalk-connector',
to: 'group:cid-42',
accountId: '__default__',
});
assert.deepEqual(store.getConversationReplyRoute('dingtalk', '__default__:conv-1'), {
channel: 'dingtalk-connector',
to: 'group:cid-42',
accountId: '__default__',
});
assert.equal(store.getConversationReplyRoute('telegram', '__default__:conv-1'), null);
assert.ok(saveCount >= 2);
});
+8
View File
@@ -171,6 +171,9 @@ test('sync writes native moonshot provider config and migrates matching managed
assert.equal(config.models.providers.moonshot.api, 'openai-completions');
assert.equal(config.agents.defaults.model.primary, 'moonshot/kimi-k2.5');
assert.deepEqual(config.commands.ownerAllowFrom, ['gateway-client']);
assert.deepEqual(config.tools.deny, ['web_search']);
assert.equal(config.tools.web.search.enabled, false);
assert.equal(config.browser.enabled, true);
const sessionStore = JSON.parse(fs.readFileSync(path.join(sessionsDir, 'sessions.json'), 'utf8'));
assert.equal(sessionStore['agent:main:lobsterai:current-session'].modelProvider, 'moonshot');
@@ -265,6 +268,11 @@ test('sync writes scheduled-task policy into managed AGENTS.md for native channe
const agentsMd = fs.readFileSync(path.join(workspaceDir, 'AGENTS.md'), 'utf8');
assert.match(agentsMd, /## Scheduled Tasks/);
assert.match(agentsMd, /## Web Search/);
assert.match(agentsMd, /Built-in `web_search` is disabled in this workspace\./);
assert.match(agentsMd, /use `web_fetch`/);
assert.match(agentsMd, /use the built-in `browser` tool/);
assert.match(agentsMd, /Native channel sessions may deny `exec`/);
assert.match(agentsMd, /native `cron` tool/i);
assert.match(agentsMd, /action: "add".*cron\.add/i);
assert.match(agentsMd, /follow the native `cron` tool schema/i);
+16 -3
View File
@@ -63,9 +63,22 @@ Handle this reminder internally. Do not relay it to the user unless explicitly r
Current time: Sunday, March 15th, 2026 — 11:27 (Asia/Shanghai)`,
});
assert.equal(entry?.role, 'system');
assert.match(entry?.text ?? '', /^System: \[Sunday, March 15th, 2026 — 11:27 \(Asia\/Shanghai\)\] ⏰ 提醒:该去买菜了!/);
assert.match(entry?.text ?? '', /Please relay this reminder to the user in a helpful and friendly way\./);
assert.deepEqual(entry, {
role: 'system',
text: '⏰ 提醒:该去买菜了!',
});
});
test('extractGatewayHistoryEntry remaps plain scheduled reminder text to a system message', () => {
const entry = extractGatewayHistoryEntry({
role: 'user',
content: '⏰ 提醒:该去钉钉打卡啦!别忘了打卡哦~',
});
assert.deepEqual(entry, {
role: 'system',
text: '⏰ 提醒:该去钉钉打卡啦!别忘了打卡哦~',
});
});
test('buildScheduledReminderSystemMessage returns null for regular user text', () => {
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import Module from 'node:module';
import test from 'node:test';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const originalModuleLoad = Module._load;
Module._load = function patchedModuleLoad(request, parent, isMain) {
if (request === 'electron') {
return {
app: {
getAppPath: () => process.cwd(),
getPath: () => process.cwd(),
},
BrowserWindow: {
getAllWindows: () => [],
},
};
}
return originalModuleLoad.call(this, request, parent, isMain);
};
const { OpenClawRuntimeAdapter } = require('../dist-electron/main/libs/agentEngine/openclawRuntimeAdapter.js');
const createStore = (messages) => {
const session = {
id: 'session-1',
title: 'Channel Session',
claudeSessionId: null,
status: 'completed',
pinned: false,
cwd: '',
systemPrompt: '',
executionMode: 'local',
activeSkillIds: [],
messages: [...messages],
createdAt: 1,
updatedAt: 1,
};
let nextId = session.messages.length + 1;
return {
session,
store: {
getSession: (sessionId) => (sessionId === session.id ? session : null),
addMessage: (sessionId, message) => {
assert.equal(sessionId, session.id);
const created = {
id: `msg-${nextId++}`,
timestamp: nextId,
metadata: {},
...message,
};
session.messages.push(created);
return created;
},
updateSession: () => {},
},
};
};
const getSystemMessages = (session) => session.messages.filter((message) => message.type === 'system');
test.after(() => {
Module._load = originalModuleLoad;
});
test('syncFullChannelHistory seeds gateway history cursor so old reminders are not replayed', async () => {
const { session, store } = createStore([
{ id: 'msg-1', type: 'user', content: 'old user', timestamp: 1, metadata: {} },
{ id: 'msg-2', type: 'assistant', content: 'old assistant', timestamp: 2, metadata: { isStreaming: false, isFinal: true } },
]);
const historyMessages = [
{ role: 'user', content: 'old user' },
{ role: 'assistant', content: 'old assistant' },
{ role: 'system', content: 'Reminder: old reminder' },
];
const adapter = new OpenClawRuntimeAdapter(store, {});
adapter.gatewayClient = {
start: () => {},
stop: () => {},
request: async () => ({ messages: historyMessages }),
};
await adapter.syncFullChannelHistory(session.id, 'dingtalk-connector:acct:user');
assert.equal(adapter.gatewayHistoryCountBySession.get(session.id), historyMessages.length);
adapter.syncSystemMessagesFromHistory(session.id, historyMessages, {
previousCountKnown: adapter.gatewayHistoryCountBySession.has(session.id),
previousCount: adapter.gatewayHistoryCountBySession.get(session.id) ?? 0,
});
assert.equal(getSystemMessages(session).length, 0);
});
test('prefetchChannelUserMessages also consumes existing reminder history backlog', async () => {
const { session, store } = createStore([
{ id: 'msg-1', type: 'user', content: 'old user', timestamp: 1, metadata: {} },
{ id: 'msg-2', type: 'assistant', content: 'old assistant', timestamp: 2, metadata: { isStreaming: false, isFinal: true } },
]);
const historyMessages = [
{ role: 'user', content: 'old user' },
{ role: 'assistant', content: 'old assistant' },
{ role: 'system', content: 'Reminder: old reminder' },
{ role: 'user', content: 'new user turn' },
];
const adapter = new OpenClawRuntimeAdapter(store, {});
adapter.gatewayClient = {
start: () => {},
stop: () => {},
request: async () => ({ messages: historyMessages }),
};
await adapter.prefetchChannelUserMessages(session.id, 'dingtalk-connector:acct:user');
assert.equal(adapter.gatewayHistoryCountBySession.get(session.id), historyMessages.length);
assert.equal(session.messages.filter((message) => message.type === 'user').length, 2);
adapter.syncSystemMessagesFromHistory(session.id, historyMessages, {
previousCountKnown: adapter.gatewayHistoryCountBySession.has(session.id),
previousCount: adapter.gatewayHistoryCountBySession.get(session.id) ?? 0,
});
assert.equal(getSystemMessages(session).length, 0);
});
test('getSessionKeysForSession prefers channel keys before managed fallback', () => {
const { store } = createStore([]);
const adapter = new OpenClawRuntimeAdapter(store, {});
adapter.rememberSessionKey('session-1', 'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374');
adapter.rememberSessionKey('session-1', 'agent:main:lobsterai:session-1');
assert.deepEqual(adapter.getSessionKeysForSession('session-1'), [
'agent:main:openai-user:dingtalk-connector:__default__:2459325231940374',
'agent:main:lobsterai:session-1',
]);
});
+120
View File
@@ -8,6 +8,9 @@ import path from 'node:path';
const require = createRequire(import.meta.url);
const {
applyBundledOpenClawRuntimeHotfixes,
patchCronReminderCurrentTimeSuffix,
patchCronReminderPromptEnvelope,
patchCronSessionDeliveryInference,
patchCronToolOwnerOnly,
patchCronOwnerFallback,
patchWecomMessageProviderExecDeny,
@@ -81,6 +84,80 @@ test('patchWecomMessageProviderExecDeny denies exec/process for WeCom native ses
);
});
test('patchCronSessionDeliveryInference restores IM routing for persisted sessions and DingTalk keys', () => {
const source = [
'function inferDeliveryFromSessionKey(agentSessionKey) {',
' const rawSessionKey = agentSessionKey?.trim();',
' if (!rawSessionKey) return null;',
' const parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));',
' if (!parsed || !parsed.rest) return null;',
' const parts = parsed.rest.split(":").filter(Boolean);',
' if (parts.length === 0) return null;',
' const head = parts[0]?.trim().toLowerCase();',
' if (!head || head === "main" || head === "subagent" || head === "acp") return null;',
' const markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");',
' if (markerIndex === -1) return null;',
' const peerId = parts.slice(markerIndex + 1).join(":").trim();',
' if (!peerId) return null;',
' let channel;',
' if (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();',
' const delivery = {',
' mode: "announce",',
' to: peerId',
' };',
' if (channel) delivery.channel = channel;',
' return delivery;',
'}',
].join('\n').replaceAll(' ', '\t');
const result = patchCronSessionDeliveryInference(source);
assert.equal(result.changed, true);
assert.match(result.content, /extractDeliveryInfo\(rawSessionKey\)/);
assert.match(result.content, /persistedDelivery\?\.channel && persistedDelivery\?\.to/);
assert.match(result.content, /head === "dingtalk-connector"/);
assert.match(result.content, /to: `user:\$\{senderId\}`/);
assert.match(result.content, /delivery\.accountId = persistedDelivery\.accountId/);
});
test('patchCronReminderPromptEnvelope strips scheduled reminder wrapper text', () => {
const source = [
'function buildCronEventPrompt(pendingEvents, opts) {',
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
'\tconst eventText = pendingEvents.join("\\n").trim();',
'\tif (!eventText) {',
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
'\t}',
'\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";',
'\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";',
'}',
].join('\n');
const result = patchCronReminderPromptEnvelope(source);
assert.equal(result.changed, true);
assert.match(result.content, /return eventText;/);
assert.doesNotMatch(result.content, /A scheduled reminder has been triggered/);
});
test('patchCronReminderCurrentTimeSuffix skips appending Current time for cron reminder prompts', () => {
const source = [
'const hasCronEvents = true;',
'const ctx = {',
'\tBody: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),',
'};',
].join('\n');
const result = patchCronReminderCurrentTimeSuffix(source);
assert.equal(result.changed, true);
assert.match(
result.content,
/Body: hasCronEvents \? prompt : appendCronStyleCurrentTimeLine\(prompt, cfg, startedAt\),/,
);
});
test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-runtime-hotfix-'));
const distDir = path.join(tmpRoot, 'dist', 'plugin-sdk');
@@ -103,6 +180,42 @@ test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files',
' "gateway"',
']);',
'const TOOL_DENY_BY_MESSAGE_PROVIDER = { voice: ["tts"] };',
'function inferDeliveryFromSessionKey(agentSessionKey) {',
'\tconst rawSessionKey = agentSessionKey?.trim();',
'\tif (!rawSessionKey) return null;',
'\tconst parsed = parseAgentSessionKey(stripThreadSuffixFromSessionKey(rawSessionKey));',
'\tif (!parsed || !parsed.rest) return null;',
'\tconst parts = parsed.rest.split(":").filter(Boolean);',
'\tif (parts.length === 0) return null;',
'\tconst head = parts[0]?.trim().toLowerCase();',
'\tif (!head || head === "main" || head === "subagent" || head === "acp") return null;',
'\tconst markerIndex = parts.findIndex((part) => part === "direct" || part === "dm" || part === "group" || part === "channel");',
'\tif (markerIndex === -1) return null;',
'\tconst peerId = parts.slice(markerIndex + 1).join(":").trim();',
'\tif (!peerId) return null;',
'\tlet channel;',
'\tif (markerIndex >= 1) channel = parts[0]?.trim().toLowerCase();',
'\tconst delivery = {',
'\t\tmode: "announce",',
'\t\tto: peerId',
'\t};',
'\tif (channel) delivery.channel = channel;',
'\treturn delivery;',
'}',
'function buildCronEventPrompt(pendingEvents, opts) {',
'\tconst deliverToUser = opts?.deliverToUser ?? true;',
'\tconst eventText = pendingEvents.join("\\n").trim();',
'\tif (!eventText) {',
'\t\tif (!deliverToUser) return "A scheduled cron event was triggered, but no event content was found. Handle this internally and reply HEARTBEAT_OK when nothing needs user-facing follow-up.";',
'\t\treturn "A scheduled cron event was triggered, but no event content was found. Reply HEARTBEAT_OK.";',
'\t}',
'\tif (!deliverToUser) return "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nHandle this reminder internally. Do not relay it to the user unless explicitly requested.";',
'\treturn "A scheduled reminder has been triggered. The reminder content is:\\n\\n" + eventText + "\\n\\nPlease relay this reminder to the user in a helpful and friendly way.";',
'}',
'const hasCronEvents = true;',
'const ctx = {',
'\tBody: appendCronStyleCurrentTimeLine(prompt, cfg, startedAt),',
'};',
].join('\n'),
'utf8',
);
@@ -132,6 +245,13 @@ test('applyBundledOpenClawRuntimeHotfixes patches matching runtime dist files',
fs.readFileSync(targetFile, 'utf8'),
/TOOL_DENY_BY_MESSAGE_PROVIDER = \{ voice: \["tts"\], wecom: \["exec", "process"\] \};/,
);
assert.match(fs.readFileSync(targetFile, 'utf8'), /extractDeliveryInfo\(rawSessionKey\)/);
assert.match(fs.readFileSync(targetFile, 'utf8'), /head === "dingtalk-connector"/);
assert.match(fs.readFileSync(targetFile, 'utf8'), /return eventText;/);
assert.match(
fs.readFileSync(targetFile, 'utf8'),
/Body: hasCronEvents \? prompt : appendCronStyleCurrentTimeLine\(prompt, cfg, startedAt\),/,
);
assert.match(fs.readFileSync(controlFile, 'utf8'), /ownerOnly: true/);
});
+1 -1
View File
@@ -21,6 +21,6 @@
"@/*": ["src/renderer/*"]
}
},
"include": ["src/renderer"],
"include": ["src/renderer", "src/common"],
"references": [{ "path": "./tsconfig.node.json" }]
}