feat(ai): support ranged cross-chat overviews

This commit is contained in:
digua committed 2026-08-24 00:25:35 +08:00
1 parent 277edaff96
commit dff4f4dfa8
13 files changed
+342 -36

No files matched your search

@@ -137,11 +137,30 @@ test('returns bounded session and per-member activity without system messages',
memberName: member.memberName,
messageCount: member.messageCount,
activeDays: member.activeDays,
activeDayKeys: member.activeDayKeys,
})),
[
{ memberId: 1, memberName: 'Me', messageCount: 2, activeDays: 2 },
{ memberId: 2, memberName: 'Alice G', messageCount: 1, activeDays: 1 },
{ memberId: 3, memberName: 'Bob', messageCount: 1, activeDays: 1 },
{
memberId: 1,
memberName: 'Me',
messageCount: 2,
activeDays: 2,
activeDayKeys: ['2024-01-01', '2024-01-02'],
},
{
memberId: 2,
memberName: 'Alice G',
messageCount: 1,
activeDays: 1,
activeDayKeys: ['2024-01-01'],
},
{
memberId: 3,
memberName: 'Bob',
messageCount: 1,
activeDays: 1,
activeDayKeys: ['2024-01-02'],
},
]
)
assert.deepEqual(facts.activeDayKeys, ['2024-01-01', '2024-01-02'])
@@ -12,6 +12,7 @@ export interface CrossChatMemberActivityFacts {
memberName: string
messageCount: number
activeDays: number
activeDayKeys: string[]
firstMessageTs: number | null
lastMessageTs: number | null
}
@@ -60,7 +61,7 @@ export function getCrossChatSessionActivityFacts(
}
| undefined
const members = db
const memberRows = db
.prepare(
`SELECT
msg.sender_id as memberId,
@@ -68,6 +69,7 @@ export function getCrossChatSessionActivityFacts(
COALESCE(m.group_nickname, m.account_name, m.platform_id) as memberName,
COUNT(*) as messageCount,
COUNT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as activeDays,
GROUP_CONCAT(DISTINCT strftime('%Y-%m-%d', msg.ts, 'unixepoch', 'localtime')) as activeDayKeys,
MIN(msg.ts) as firstMessageTs,
MAX(msg.ts) as lastMessageTs
FROM message msg
@@ -76,7 +78,13 @@ export function getCrossChatSessionActivityFacts(
GROUP BY msg.sender_id, m.platform_id, m.group_nickname, m.account_name
ORDER BY msg.sender_id`
)
.all(...timeFilter.params) as unknown as CrossChatMemberActivityFacts[]
.all(...timeFilter.params) as unknown as Array<
Omit<CrossChatMemberActivityFacts, 'activeDayKeys'> & { activeDayKeys: string | null }
>
const members = memberRows.map((row) => ({
...row,
activeDayKeys: row.activeDayKeys ? row.activeDayKeys.split(',').sort() : [],
}))
const dataRange = db
.prepare(
@@ -43,6 +43,8 @@ describe('cross-chat agent prompt', () => {
assert.match(prompt, /mode=owner_activity/)
assert.match(prompt, /mode=total_activity/)
assert.match(prompt, /两种口径不能互换/)
assert.match(prompt, /get_cross_chat_overview.*明确时间范围/)
assert.match(prompt, /不负责发现或全局排名/)
assert.match(prompt, /所有参与者都出现.*严格交集/)
assert.match(prompt, /群名称、成员结构、共同活跃和相邻发言只是调查导航信号/)
assert.match(prompt, /partial\/skipped_budget 不是零/)
@@ -91,6 +93,8 @@ describe('cross-chat agent prompt', () => {
assert.match(prompt, /mode=owner_activity/)
assert.match(prompt, /mode=total_activity/)
assert.match(prompt, /Never swap the two metrics/)
assert.match(prompt, /get_cross_chat_overview with the exact requested time range/)
assert.match(prompt, /does not discover or rank sessions/)
assert.match(prompt, /investigation signals, not proof/)
assert.match(prompt, /partial or skipped_budget never means zero/)
assert.match(prompt, /current date is.*August 23, 2026/i)
@@ -238,7 +238,7 @@ export function buildCrossChatSystemPrompt(locale = 'zh-CN', now = new Date()):
- rank_group_sessions 的 owner_activity 只按本人消息量排序,缺少或无法解析“我”的群是 coverage 缺口;total_activity 按群总消息量排序,不要求 owner 可解析。两个 mode 都默认排除用户标记“当前对话中没有我”的会话,并且只有 coverage.complete=true 时才能称为完整全局排行。
- 没有直接回复或原文证据时,只能确认共同来源或同场活跃,并明确具体关系仍不确定。只有 coverage 完整且时间范围明确,才允许给出“没有发生过”或“没有互动”的强负面判断;partial/skipped_budget 不是零。
- search_messages_globally 提供关键词时是字面 LIKE 检索,不是语义搜索。用户问“聊过什么”等开放问题时,先限定联系人或群聊 scopes,再无关键词抽取指定时间范围内的消息;工具自行控制证据量,优先返回私聊证据,剩余容量按目标人物在群聊中的匹配活跃度分配,并自动附带命中消息周围的上下文。不要传入消息数量、会话数量或执行时长预算,也不要对全部会话做无关键词扫描。
- 比较联系人或群聊时,优先分别给出概览并检索有来源的证据。需要理解命中消息时,用 session_id + message_id 获取上下文。
- 比较已经解析出的联系人或群聊时,用 get_cross_chat_overview 传入与问题一致的明确时间范围,读取精确消息数、活跃天数、成员活动与 owner 覆盖;它不负责发现或全局排名。需要解释差异时,再检索有来源的原文证据;需要理解命中消息时,用 session_id + message_id 获取上下文。
- 工具返回 coverage 和 truncated。覆盖不完整或被截断时必须明确说明抽样范围,禁止把样本表述成全量结论。
- 涉及单个联系人的限时搜索返回 0 条时,不要只说“什么都没有”。必须从 resolve_chat_entities 返回的私聊 session 元数据中说明最近一条已导入私聊的具体时间,以及它是否落在查询窗口之外;没有私聊 session 或没有可用时间时也要明确说明。之后再询问用户是否扩大范围。
- sender=owner 时还要检查 coverage.ownerResolution;存在未设置或无法解析“我”的会话时,必须说明对应覆盖缺口,禁止通过昵称猜测本人。
@@ -275,7 +275,7 @@ Tool and conclusion rules:
- rank_group_sessions owner_activity ranks only by the user's own message count and treats missing or unresolved owners as coverage gaps. total_activity ranks by total group messages and does not require owner resolution. Both modes exclude sessions marked as not containing the user, and only coverage.complete=true supports an exhaustive global-ranking claim.
- Without direct replies or source text, state only shared-source or co-activity facts and keep the relationship uncertain. Strong negative claims require complete coverage and an explicit time range. partial or skipped_budget never means zero.
- Search with keywords is literal LIKE matching, not semantic retrieval. For open-ended questions such as "what did we discuss", first resolve contact or session scopes, then sample messages inside the requested time range without keywords. The tool controls evidence volume, prioritizes private-chat evidence, allocates remaining capacity to groups by the target person's matching activity, and automatically includes surrounding context. Do not pass message-count, session-count, or execution-time budgets, and never scan every session without keywords.
- For comparisons, prefer separate overviews and source-backed evidence. Expand a hit with session_id plus message_id when context is needed.
- For comparisons across already resolved contacts or sessions, call get_cross_chat_overview with the exact requested time range and use its deterministic message counts, active days, member activity, and owner coverage. It does not discover or rank sessions. Retrieve source-backed message evidence only when the difference needs explanation, and expand a hit with session_id plus message_id when context is needed.
- Respect coverage and truncated fields. State incomplete coverage or sampling explicitly and never present a sample as exhaustive.
- When a time-bounded search about one contact returns zero messages, do not merely say that nothing was found. From the direct private session metadata returned by resolve_chat_entities, state the latest imported private-chat timestamp as an exact date and whether it falls outside the requested window. Explicitly say when no private session or timestamp is available, then ask before widening the range.
- For sender=owner, inspect coverage.ownerResolution and disclose sessions where the owner is missing or unresolved. Never guess the owner from display names.
+1
View File
@@ -551,6 +551,7 @@ export type {
CrossChatPrivateContactsRankingRequest,
CrossChatPrivateContactsRankingResult,
CrossChatOverviewItem,
CrossChatOverviewMemberActivity,
CrossChatOverviewRequest,
CrossChatOverviewResult,
CrossChatOwnerStatus,
@@ -15,6 +15,7 @@ export type {
CrossChatPrivateContactsRankingRequest,
CrossChatPrivateContactsRankingResult,
CrossChatOverviewItem,
CrossChatOverviewMemberActivity,
CrossChatOverviewRequest,
CrossChatOverviewResult,
CrossChatOwnerStatus,
@@ -1807,21 +1807,88 @@ test('search honors interruption while resolving unscoped candidates', async ()
test('overview preserves separate member scopes for contacts in the same group', async () => {
const { env, contactsService } = createFixture()
try {
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
const service = createCrossChatAnalysisService({
adapter: env.adapter,
contactsService,
now: () => 1_790_000_000_000,
})
const result = await service.getOverview({
scopes: [
{ sessionId: 'group-work', memberIds: [20], label: 'Alice in Work group' },
{ sessionId: 'group-work', memberIds: [21], label: 'Bob in Work group' },
],
startTs: 305,
endTs: 320,
})
assert.deepEqual(
result.items.map((item) => [item.label, item.totalMessages, item.firstMessageTs, item.lastMessageTs]),
result.items.map((item) => [
item.label,
item.totalMessages,
item.activeDays,
item.activeMembers,
item.firstMessageTs,
item.lastMessageTs,
item.memberActivities.map((member) => [member.memberName, member.messageCount]),
]),
[
['Alice in Work group', 2, 300, 320],
['Bob in Work group', 1, 310, 310],
['Alice in Work group', 1, 1, 1, 320, 320, [['Alice', 1]]],
['Bob in Work group', 1, 1, 1, 310, 310, [['Bob', 1]]],
]
)
assert.deepEqual(
result.items[0]?.topMembers.map((member) => [member.memberName, member.messageCount]),
[
['Alice', 1],
['Bob', 1],
]
)
assert.equal(result.items[0]?.ownerStatus, 'missing')
assert.deepEqual(result.appliedRange, {
startTs: 305,
endTs: 320,
dataEarliestMessageTs: 300,
dataLatestMessageTs: 320,
currentTs: 1_790_000_000,
})
assert.equal(result.coverage.missingOwnerSessions, 2)
assert.equal(result.coverage.complete, true)
} finally {
env.cleanup()
}
})
test('overview reports resolved owner activity without changing whole-session totals', async () => {
const { env, contactsService } = createFixture()
env.seed({
id: 'overview-owner',
name: 'Overview owner group',
type: 'group',
ownerPlatformId: 'owner',
members: [
{ id: 1, platformId: 'owner', name: 'Me' },
{ id: 2, platformId: 'alice', name: 'Alice' },
],
messages: [
{ id: 1, senderId: 1, ts: 400, content: 'owner-early' },
{ id: 2, senderId: 2, ts: 410, content: 'alice' },
{ id: 3, senderId: 1, ts: 420, content: 'owner-late' },
],
})
try {
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
const result = await service.getOverview({
scopes: [{ sessionId: 'overview-owner' }],
startTs: 405,
endTs: 420,
})
assert.equal(result.items[0]?.totalMessages, 2)
assert.equal(result.items[0]?.activeMembers, 2)
assert.equal(result.items[0]?.ownerStatus, 'resolved')
assert.equal(result.items[0]?.ownerMessages, 1)
assert.equal(result.items[0]?.ownerActiveDays, 1)
assert.deepEqual(result.items[0]?.memberActivities, [])
} finally {
env.cleanup()
}
@@ -5,17 +5,16 @@ import {
getNonSystemMembersForContacts,
getParticipantSessionFacts,
getParticipantSetInteractionFacts,
getMembers,
getRecentMessages,
getSegmentSummaries,
getSearchMessageContext as getCoreSearchMessageContext,
getSessionMeta,
getSessionOverview,
resolveContactMember,
resolveOwnerMember,
searchMessagesByKeywords,
shouldScopeContactToSession,
type ContactMemberRef,
type CrossChatSessionActivityFacts,
type DatabaseAdapter,
type ParticipantSetInteractionFacts,
} from '@openchatlab/core'
@@ -32,8 +31,10 @@ import {
type CrossChatMessageSource,
type CrossChatOperationOptions,
type CrossChatOverviewItem,
type CrossChatOverviewMemberActivity,
type CrossChatOverviewRequest,
type CrossChatOverviewResult,
type CrossChatOwnerStatus,
type CrossChatParticipantRef,
type CrossChatGroupSessionRankItem,
type CrossChatGroupSessionsRankingRequest,
@@ -85,6 +86,7 @@ const GROUP_SESSIONS_RANKING_ALGORITHM_VERSION = 'group-sessions-ranking-v1'
const DEFAULT_RANKING_LIMIT = 10
const MAX_RANKING_LIMIT = 50
const RANKING_MAX_WALL_TIME_MS = 30_000
const OVERVIEW_TOP_MEMBERS_LIMIT = 5
export interface CrossChatAnalysisServiceDeps {
adapter: SessionRuntimeAdapter
@@ -1134,13 +1136,21 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
): Promise<CrossChatOverviewResult> {
throwIfAborted(options.signal)
const scopes = normalizeOverviewScopes(request.scopes)
const range = normalizeInspectionRange(request.startTs, request.endTs, request.recentDays, () => this.now())
const maxSessions = clampInteger(request.maxSessions, DEFAULT_MAX_SESSIONS, 1, MAX_MAX_SESSIONS)
const maxWallTimeMs = clampInteger(request.maxWallTimeMs, DEFAULT_MAX_WALL_TIME_MS, 1, MAX_MAX_WALL_TIME_MS)
const selected = scopes.slice(0, maxSessions)
const excludedSessionIds = new Set(this.deps.getExcludedSessionIds?.() ?? [])
const startedAt = this.now()
const items: CrossChatOverviewItem[] = []
let failedSessions = 0
const failedSessionIds: string[] = []
let excludedSessions = 0
let missingOwnerSessions = 0
let unresolvedOwnerSessions = 0
let processedSessions = 0
let dataEarliestMessageTs: number | null = null
let dataLatestMessageTs: number | null = null
const truncatedReasons = new Set<'session_budget' | 'time_budget'>()
if (selected.length < scopes.length) truncatedReasons.add('session_budget')
@@ -1161,16 +1171,43 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
const db = this.deps.adapter.openReadonly(scope.sessionId)
if (!db) {
failedSessions++
failedSessionIds.push(scope.sessionId)
continue
}
const descriptor = getSessionDescriptor(scope.sessionId, db)
if (!descriptor) {
failedSessions++
failedSessionIds.push(scope.sessionId)
continue
}
items.push(buildOverviewItem(descriptor, db, scope))
const facts = getCrossChatSessionActivityFacts(db, range)
dataEarliestMessageTs = minNullable(dataEarliestMessageTs, facts.dataEarliestMessageTs)
dataLatestMessageTs = maxNullable(dataLatestMessageTs, facts.dataLatestMessageTs)
let ownerStatus: CrossChatOwnerStatus = 'missing'
let ownerMemberId: number | null = null
if (excludedSessionIds.has(scope.sessionId)) {
ownerStatus = 'excluded'
excludedSessions++
} else {
const meta = getSessionMeta(db)
if (!meta?.ownerId?.trim()) {
missingOwnerSessions++
} else {
const owner = resolveOwnerMember(db)
if (!owner) {
ownerStatus = 'unresolved'
unresolvedOwnerSessions++
} else {
ownerStatus = 'resolved'
ownerMemberId = owner.id
}
}
}
items.push(buildOverviewItem(descriptor, db, scope, facts, ownerStatus, ownerMemberId))
} catch (error) {
failedSessions++
failedSessionIds.push(scope.sessionId)
appLogger.warn('cross-chat-analysis', `failed to build session overview: ${scope.sessionId}`, error)
} finally {
processedSessions++
@@ -1179,11 +1216,22 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
}
options.onProgress?.({ processedSessions, totalSessions: selected.length })
return {
appliedRange: {
...range,
dataEarliestMessageTs,
dataLatestMessageTs,
currentTs: Math.floor(this.now() / 1000),
},
items,
coverage: {
candidateSessions: scopes.length,
analyzedSessions: items.length,
excludedSessions,
missingOwnerSessions,
unresolvedOwnerSessions,
failedSessions,
failedSessionIds,
complete: truncatedReasons.size === 0 && failedSessions === 0,
truncated: truncatedReasons.size > 0,
truncatedReasons: [...truncatedReasons],
},
@@ -1559,31 +1607,88 @@ function normalizeOverviewScopes(scopes: CrossChatSearchScope[]): CrossChatSearc
function buildOverviewItem(
descriptor: CrossChatSessionDescriptor,
db: DatabaseAdapter,
scope: CrossChatSearchScope
scope: CrossChatSearchScope,
facts: CrossChatSessionActivityFacts,
ownerStatus: CrossChatOwnerStatus,
ownerMemberId: number | null
): CrossChatOverviewItem {
if (!scope.memberIds) {
const overview = getSessionOverview(db)
const rosterById = new Map(getNonSystemMembersForContacts(db).map((member) => [member.id, member]))
const factsByMemberId = new Map(facts.members.map((member) => [member.memberId, member]))
const memberActivities = (scope.memberIds ?? []).flatMap((memberId) => {
const rosterMember = rosterById.get(memberId)
if (!rosterMember) return []
const activity = factsByMemberId.get(memberId)
return [
{
memberId,
platformId: rosterMember.platformId,
memberName: rosterMember.name,
messageCount: activity?.messageCount ?? 0,
activeDays: activity?.activeDays ?? 0,
firstMessageTs: activity?.firstMessageTs ?? null,
lastMessageTs: activity?.lastMessageTs ?? null,
} satisfies CrossChatOverviewMemberActivity,
]
})
const selectedFacts = scope.memberIds
? scope.memberIds.flatMap((memberId) => factsByMemberId.get(memberId) ?? [])
: []
const selectedActiveDayKeys = new Set(selectedFacts.flatMap((member) => member.activeDayKeys))
const ownerActivity = ownerMemberId === null ? undefined : factsByMemberId.get(ownerMemberId)
const topMembers =
descriptor.sessionType === ChatType.GROUP
? [...facts.members]
.sort(
(left, right) =>
right.messageCount - left.messageCount ||
(right.lastMessageTs ?? Number.NEGATIVE_INFINITY) - (left.lastMessageTs ?? Number.NEGATIVE_INFINITY) ||
left.memberId - right.memberId
)
.slice(0, OVERVIEW_TOP_MEMBERS_LIMIT)
.map(toOverviewMemberActivity)
: []
const totalMessages = scope.memberIds
? selectedFacts.reduce((sum, member) => sum + member.messageCount, 0)
: facts.totalMessages
const firstMessageTs = scope.memberIds
? selectedFacts.reduce<number | null>((earliest, member) => minNullable(earliest, member.firstMessageTs), null)
: facts.firstMessageTs
const lastMessageTs = scope.memberIds
? selectedFacts.reduce<number | null>((latest, member) => maxNullable(latest, member.lastMessageTs), null)
: facts.lastMessageTs
return {
...descriptor,
label: scope.label ?? descriptor.sessionName,
totalMessages: overview.totalMessages,
firstMessageTs: overview.firstMessageTs,
lastMessageTs: overview.lastMessageTs,
...(scope.memberIds
? {
memberIds: scope.memberIds,
memberNames: memberActivities.map((member) => member.memberName),
}
: {}),
memberActivities,
totalMessages,
activeDays: scope.memberIds ? selectedActiveDayKeys.size : facts.activeDays,
activeMembers: scope.memberIds ? selectedFacts.length : facts.activeMembers,
firstMessageTs,
lastMessageTs,
ownerStatus,
ownerMessages: ownerStatus === 'resolved' ? (ownerActivity?.messageCount ?? 0) : null,
ownerActiveDays: ownerStatus === 'resolved' ? (ownerActivity?.activeDays ?? 0) : null,
topMembers,
}
}
const members = getMembers(db)
const memberNames = members.filter((member) => scope.memberIds?.includes(member.id)).map((member) => member.name)
const latest = searchMessagesByKeywords(db, [], { senderIds: scope.memberIds, sort: 'desc', limit: 1 })
const earliest = searchMessagesByKeywords(db, [], { senderIds: scope.memberIds, sort: 'asc', limit: 1 })
function toOverviewMemberActivity(
member: CrossChatSessionActivityFacts['members'][number]
): CrossChatOverviewMemberActivity {
return {
...descriptor,
label: scope.label ?? descriptor.sessionName,
memberIds: scope.memberIds,
memberNames,
totalMessages: latest.total ?? 0,
firstMessageTs: earliest.messages[0]?.timestamp ?? null,
lastMessageTs: latest.messages[0]?.timestamp ?? null,
memberId: member.memberId,
platformId: member.platformId,
memberName: member.memberName,
messageCount: member.messageCount,
activeDays: member.activeDays,
firstMessageTs: member.firstMessageTs,
lastMessageTs: member.lastMessageTs,
}
}
@@ -12,6 +12,7 @@ export type {
CrossChatPrivateContactsRankingRequest,
CrossChatPrivateContactsRankingResult,
CrossChatOverviewItem,
CrossChatOverviewMemberActivity,
CrossChatOverviewRequest,
CrossChatOverviewResult,
CrossChatOwnerStatus,
@@ -71,6 +71,7 @@ export type {
CrossChatPrivateContactsRankingRequest,
CrossChatPrivateContactsRankingResult,
CrossChatOverviewItem,
CrossChatOverviewMemberActivity,
CrossChatOverviewRequest,
CrossChatOverviewResult,
CrossChatOwnerStatus,
+26
View File
@@ -682,25 +682,51 @@ export interface CrossChatMessageContextResult {
export interface CrossChatOverviewRequest {
scopes: CrossChatSearchScope[]
startTs?: number
endTs?: number
recentDays?: number
maxSessions?: number
maxWallTimeMs?: number
}
export interface CrossChatOverviewMemberActivity {
memberId: number
platformId: string
memberName: string
messageCount: number
activeDays: number
firstMessageTs: number | null
lastMessageTs: number | null
}
export interface CrossChatOverviewItem extends CrossChatSessionDescriptor {
label: string
memberIds?: number[]
memberNames?: string[]
memberActivities: CrossChatOverviewMemberActivity[]
totalMessages: number
activeDays: number
activeMembers: number
firstMessageTs: number | null
lastMessageTs: number | null
ownerStatus: CrossChatOwnerStatus
ownerMessages: number | null
ownerActiveDays: number | null
topMembers: CrossChatOverviewMemberActivity[]
}
export interface CrossChatOverviewResult {
appliedRange: CrossChatStatisticsTimeRange
items: CrossChatOverviewItem[]
coverage: {
candidateSessions: number
analyzedSessions: number
excludedSessions: number
missingOwnerSessions: number
unresolvedOwnerSessions: number
failedSessions: number
failedSessionIds: string[]
complete: boolean
truncated: boolean
truncatedReasons: Array<'session_budget' | 'time_budget'>
}
@@ -423,11 +423,23 @@ function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): C
messages: [],
}),
getOverview: async (): Promise<CrossChatOverviewResult> => ({
appliedRange: {
startTs: null,
endTs: null,
dataEarliestMessageTs: null,
dataLatestMessageTs: null,
currentTs: 1_790_000_000,
},
items: [],
coverage: {
candidateSessions: 0,
analyzedSessions: 0,
excludedSessions: 0,
missingOwnerSessions: 0,
unresolvedOwnerSessions: 0,
failedSessions: 0,
failedSessionIds: [],
complete: true,
truncated: false,
truncatedReasons: [],
},
@@ -496,6 +508,64 @@ describe('cross-chat agent registry', () => {
assert.deepEqual(result.data, privateContactsRankingResult())
})
it('forwards exact overview ranges while keeping execution budgets internal', async () => {
let captured: Parameters<CrossChatAnalysisToolService['getOverview']>[0] | undefined
const context = createContext({
getOverview: async (request) => {
captured = request
return {
appliedRange: {
startTs: request.startTs ?? null,
endTs: request.endTs ?? null,
dataEarliestMessageTs: 100,
dataLatestMessageTs: 200,
currentTs: 1_790_000_000,
},
items: [],
coverage: {
candidateSessions: 1,
analyzedSessions: 1,
excludedSessions: 0,
missingOwnerSessions: 0,
unresolvedOwnerSessions: 0,
failedSessions: 0,
failedSessionIds: [],
complete: true,
truncated: false,
truncatedReasons: [],
},
}
},
})
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'get_cross_chat_overview')
assert.ok(tool)
assert.equal(tool.inputSchema.properties?.max_sessions, undefined)
assert.equal(tool.inputSchema.properties?.max_wall_time_ms, undefined)
const result = await tool.handler(
{
scopes: [{ sessionId: 'session-a', memberIds: [2], label: 'Alice' }],
start_time: '2026-01-01 00:00',
end_time: '2026-08-23 12:00',
max_sessions: 1,
max_wall_time_ms: 1,
},
context
)
assert.equal(typeof captured?.startTs, 'number')
assert.equal(typeof captured?.endTs, 'number')
assert.equal(captured?.maxSessions, undefined)
assert.equal(captured?.maxWallTimeMs, undefined)
assert.deepEqual(result.data?.appliedRange, {
startTs: captured?.startTs ?? null,
endTs: captured?.endTs ?? null,
dataEarliestMessageTs: 100,
dataLatestMessageTs: 200,
currentTs: 1_790_000_000,
})
})
it('requires an explicit group-ranking mode and forwards the exact range', async () => {
let captured: Parameters<CrossChatAnalysisToolService['rankGroupSessions']>[0] | undefined
const context = createContext({
@@ -113,8 +113,8 @@ const overviewSchema: JsonSchema = {
description: 'Resolved session/member scopes to summarize separately',
items: scopeItems,
},
max_sessions: { type: 'number', description: 'Maximum sessions to analyze' },
max_wall_time_ms: { type: 'number', description: 'Maximum wall time for this analysis' },
recent_days: recentDaysProperty,
...timeParamProperties,
},
required: ['scopes'],
}
@@ -386,11 +386,13 @@ async function overviewHandler(
params: Record<string, unknown>,
context: CrossChatToolExecutionContext
): Promise<ToolResult> {
const timeFilter = parseExtendedTimeParams(params)
const result = await context.analysisService.getOverview(
{
scopes: parseScopes(params.scopes),
maxSessions: parseOptionalNumber(params.max_sessions),
maxWallTimeMs: parseOptionalNumber(params.max_wall_time_ms),
startTs: timeFilter?.startTs,
endTs: timeFilter?.endTs,
recentDays: parseOptionalNumber(params.recent_days),
},
{
signal: context.abortSignal,
@@ -403,6 +405,7 @@ async function overviewHandler(
}
)
const data = {
appliedRange: result.appliedRange,
items: result.items.map(({ memberNames: _memberNames, ...item }) => item),
coverage: result.coverage,
}
@@ -596,7 +599,7 @@ export const getCrossChatMessageContextTool: ToolDefinition<CrossChatToolExecuti
export const getCrossChatOverviewTool: ToolDefinition<CrossChatToolExecutionContext> = {
name: 'get_cross_chat_overview',
description:
'Get separate message-count and time-range overviews for resolved contact/session scopes. This is a basic comparison tool, not arbitrary SQL or single-chat deep analytics.',
'Compare already-resolved contact or session scopes within an exact time range. Returns deterministic message counts, active days and members, per-requested-member activity, owner coverage, fixed top members for groups, dataset cutoff, and completeness. It does not discover or rank sessions globally.',
inputSchema: overviewSchema,
handler: overviewHandler,
category: 'core',