fix(ai): harden contact session inspection

Use the all-history contact snapshot and validate raw session candidates inside the cancellable scan loop. Bound model-visible structural results to the configured token budget while preserving full tool details and continuation metadata.
This commit is contained in:
digua committed 2026-08-22 20:29:40 +08:00
1 parent f26c6f6eba
commit 10e0825578
4 files changed
+323 -6

No files matched your search

@@ -466,6 +466,103 @@ test('contact session inspection scans imported sessions and separates own messa
}
})
test('contact session inspection uses the all-history contact snapshot', async () => {
const { env, contactsService: fixtureContactsService } = createFixture()
const detailPresets: Array<string | undefined> = []
try {
const contactsService: Pick<ContactsService, 'getContactDetail' | 'getContactsPage'> = {
...fixtureContactsService,
getContactDetail: (key, options) => {
detailPresets.push(options?.timeRangePreset)
return options?.timeRangePreset === 'all' ? fixtureContactsService.getContactDetail(key, options) : detail(null)
},
}
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
const result = await service.inspectContactSessions({ contactKey: 'test:alice' })
assert.equal(result.contact?.contactKey, 'test:alice')
assert.deepEqual(
result.sessions.map((session) => session.sessionId),
['group-work', 'private-alice']
)
assert.deepEqual(detailPresets, ['all'])
} finally {
env.cleanup()
}
})
test('contact session inspection validates raw candidates inside failure, time, and cancellation boundaries', async () => {
const { env, contactsService } = createFixture()
try {
const candidateIds = [...env.adapter.listSessionCandidateIds!(), 'damaged']
let eagerEnumerationCalled = false
const failureAdapter: SessionRuntimeAdapter = {
...env.adapter,
listSessionIds: () => {
eagerEnumerationCalled = true
throw new Error('eager enumeration should not be used')
},
listSessionCandidateIds: () => candidateIds,
openReadonly: (sessionId) => {
if (sessionId === 'damaged') throw new Error('fixture failure')
return env.adapter.openReadonly(sessionId)
},
}
const failureResult = await createCrossChatAnalysisService({
adapter: failureAdapter,
contactsService,
}).inspectContactSessions({ contactKey: 'test:alice' })
assert.equal(eagerEnumerationCalled, false)
assert.deepEqual(
failureResult.sessions.map((session) => session.sessionId),
['group-work', 'private-alice']
)
assert.deepEqual(failureResult.coverage.failedSessionIds, ['damaged'])
let now = 0
const openedSessions: string[] = []
const timedAdapter: SessionRuntimeAdapter = {
...env.adapter,
openReadonly: (sessionId) => {
openedSessions.push(sessionId)
const db = env.adapter.openReadonly(sessionId)
now += 9_000
return db
},
}
const timedResult = await createCrossChatAnalysisService({
adapter: timedAdapter,
contactsService,
now: () => now,
}).inspectContactSessions({ contactKey: 'test:alice', maxWallTimeMs: 8_000 })
assert.equal(openedSessions.length, 1)
assert.equal(timedResult.coverage.scannedSessions, 1)
assert.ok(timedResult.coverage.truncatedReasons.includes('time_budget'))
assert.ok(timedResult.coverage.nextCursor)
const controller = new AbortController()
const service = createCrossChatAnalysisService({ adapter: env.adapter, contactsService })
await assert.rejects(
() =>
service.inspectContactSessions(
{ contactKey: 'test:alice' },
{
signal: controller.signal,
onProgress: (progress) => {
if (progress.currentSessionId === 'group-work') controller.abort()
},
}
),
{ name: 'AbortError' }
)
} finally {
env.cleanup()
}
})
test('contact session inspection honors time ranges, session-scoped identity, and continuation cursors', async () => {
const { env, contactsService } = createFixture()
try {
@@ -267,17 +267,23 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
const includeRosterOnly = request.includeRosterOnly !== false
const pageSize = clampInteger(request.pageSize, DEFAULT_INSPECTION_PAGE_SIZE, 1, MAX_INSPECTION_PAGE_SIZE)
const maxWallTimeMs = clampInteger(request.maxWallTimeMs, DEFAULT_MAX_WALL_TIME_MS, 1, MAX_MAX_WALL_TIME_MS)
const detail = this.deps.contactsService.getContactDetail(contactKey, { acceptStale: true })
const detail = this.deps.contactsService.getContactDetail(contactKey, {
acceptStale: true,
timeRangePreset: 'all',
})
const contact = detail.contact
if (!contact) {
return emptyContactSessionsResult(detail.cache.status, range)
}
const startedAt = this.now()
const candidateSessionIds = contact.sessionScoped
? contact.sessionId
? [contact.sessionId]
: []
: [...new Set(this.deps.adapter.listSessionIds())].sort((left, right) => left.localeCompare(right))
: [...new Set(this.deps.adapter.listSessionCandidateIds?.() ?? this.deps.adapter.listSessionIds())].sort(
(left, right) => left.localeCompare(right)
)
const cursorFingerprint = createInspectionFingerprint({
candidateSessionIds,
contactKey,
@@ -286,7 +292,6 @@ class DefaultCrossChatAnalysisService implements CrossChatAnalysisService {
includeRosterOnly,
})
const cursor = parseInspectionCursor(request.cursor, cursorFingerprint, candidateSessionIds)
const startedAt = this.now()
const sessions: CrossChatContactSessionsResult['sessions'] = []
const failedSessionIds: string[] = []
const truncatedReasons = new Set<CrossChatContactSessionsResult['coverage']['truncatedReasons'][number]>()
@@ -36,6 +36,67 @@ function messageSource(
}
}
function contactSessionsResult(sessionCount: number, sessionName = 'Work group'): CrossChatContactSessionsResult {
return {
algorithmVersion: 'test',
contact: {
contactKey: 'test:alice',
displayName: 'Alice',
platform: 'test',
sessionScoped: false,
},
appliedRange: {
startTs: null,
endTs: null,
dataEarliestMessageTs: 100,
dataLatestMessageTs: 200,
},
summary: {
scope: 'current_batch',
matchedSessions: sessionCount,
privateSessions: 0,
groupSessions: sessionCount,
spokeSessions: sessionCount,
rosterOnlySessions: 0,
ownMessageCount: sessionCount * 10,
firstOwnMessageTs: 100,
lastOwnMessageTs: 200,
},
sessions: Array.from({ length: sessionCount }, (_, index) => ({
sessionId: `group-${index}`,
sessionName,
sessionType: ChatType.GROUP,
platform: 'test',
lastMessageTs: 200,
memberId: index + 1,
memberName: 'Alice',
presence: 'spoke',
presenceObservedInRange: true,
ownMessageCount: 10,
sessionMessageCount: 100,
messageShare: 0.1,
firstOwnMessageTs: 100,
lastOwnMessageTs: 200,
activeDays: 2,
memberCount: 20,
sessionFirstMessageTs: 90,
})),
coverage: {
candidateSessions: 20,
scannedSessions: sessionCount,
matchedSessions: sessionCount,
returnedSessions: sessionCount,
failedSessions: 0,
failedSessionIds: [],
complete: false,
nextCursor: 'next-page',
truncated: true,
truncatedReasons: ['page_size'],
contactCacheStatus: 'fresh',
},
}
}
function createContext(overrides: Partial<CrossChatAnalysisToolService> = {}): CrossChatToolExecutionContext {
const service: CrossChatAnalysisToolService = {
lookupContact: (query: string): CrossChatContactLookupResult => ({
@@ -209,6 +270,38 @@ describe('cross-chat agent registry', () => {
await assert.rejects(async () => tool.handler({ contact_key: '' }, context), /contact_key is required/)
})
it('keeps contact inspection within the model token budget without dropping full tool details', async () => {
let capturedPageSize: number | undefined
const longSessionName = '超长群聊名称'.repeat(500)
let tokenCountCalls = 0
const countTokens = (text: string) => {
tokenCountCalls++
return Math.ceil(Array.from(text).length / 4)
}
const context = createContext({
inspectContactSessions: async (request) => {
capturedPageSize = request.pageSize
return contactSessionsResult(request.pageSize ?? 12, longSessionName)
},
})
context.maxToolResultTokens = 1_000
context.countTokens = countTokens
const tool = CROSS_CHAT_AGENT_TOOL_REGISTRY.find((item) => item.name === 'inspect_contact_sessions')
assert.ok(tool)
const result = await tool.handler({ contact_key: 'test:alice', page_size: 12 }, context)
const data = result.data as CrossChatContactSessionsResult
const modelData = JSON.parse(result.content) as { sessions?: Array<{ sessionName?: string }> }
assert.ok(capturedPageSize && capturedPageSize < 12)
assert.equal(data.sessions.length, capturedPageSize)
assert.equal(data.sessions[0]?.sessionName, longSessionName)
assert.notEqual(modelData.sessions?.[0]?.sessionName, longSessionName)
assert.ok(data.coverage.truncatedReasons.includes('tool_result_budget'))
assert.ok(countTokens(result.content) <= context.maxToolResultTokens)
assert.ok(tokenCountCalls > 0)
})
it('resolves a unique contact name before continuing with stable scopes', async () => {
let resolvedRefs: AIEntityRef[] = []
const context = createContext({
@@ -124,6 +124,13 @@ const inspectContactSessionsSchema: JsonSchema = {
required: ['contact_key'],
}
const DEFAULT_CONTACT_INSPECTION_PAGE_SIZE = 50
const MAX_CONTACT_INSPECTION_PAGE_SIZE = 100
const CONTACT_MODEL_LABEL_MAX_LENGTH = 80
const TOOL_RESULT_CHARS_PER_TOKEN = 4
const TOOL_RESULT_BASE_CHARS = 1_500
const CONTACT_SESSION_ESTIMATED_CHARS = 650
async function resolveHandler(
params: Record<string, unknown>,
context: CrossChatToolExecutionContext
@@ -279,6 +286,8 @@ async function inspectContactSessionsHandler(
context: CrossChatToolExecutionContext
): Promise<ToolResult> {
const timeFilter = parseExtendedTimeParams(params)
const requestedPageSize = parseOptionalNumber(params.page_size)
const budgetedPageSize = limitContactPageSizeToBudget(requestedPageSize, context.maxToolResultTokens)
const result = await context.analysisService.inspectContactSessions(
{
contactKey: requireString(params.contact_key, 'contact_key'),
@@ -286,7 +295,7 @@ async function inspectContactSessionsHandler(
endTs: timeFilter?.endTs,
includeRosterOnly: params.include_roster_only === undefined ? true : requireBoolean(params.include_roster_only),
cursor: typeof params.cursor === 'string' && params.cursor.trim() ? params.cursor.trim() : undefined,
pageSize: parseOptionalNumber(params.page_size),
pageSize: budgetedPageSize.value,
maxWallTimeMs: parseOptionalNumber(params.max_wall_time_ms),
},
{
@@ -299,8 +308,15 @@ async function inspectContactSessionsHandler(
}),
}
)
const data: CrossChatContactSessionsResult = result
return { content: JSON.stringify(data), data }
const countTokens = context.countTokens ?? estimatePayloadTokens
const pageBudgetTruncated = budgetedPageSize.limited && result.coverage.truncatedReasons.includes('page_size')
const payloadExceedsBudget =
!!context.maxToolResultTokens &&
context.maxToolResultTokens > 0 &&
countTokens(JSON.stringify(result)) > context.maxToolResultTokens
const data = pageBudgetTruncated || payloadExceedsBudget ? withContactToolResultBudgetReason(result) : result
const modelData = limitContactResultToBudget(data, context.maxToolResultTokens, countTokens)
return { content: JSON.stringify(modelData), data }
}
export const resolveChatEntitiesTool: ToolDefinition<CrossChatToolExecutionContext> = {
@@ -399,6 +415,112 @@ function parseScopes(value: unknown): CrossChatSearchScope[] {
})
}
function limitContactPageSizeToBudget(
requestedPageSize: number | undefined,
maxToolResultTokens: number | undefined
): { value: number | undefined; limited: boolean } {
if (!maxToolResultTokens || maxToolResultTokens <= 0) {
return { value: requestedPageSize, limited: false }
}
const requested = Math.min(
MAX_CONTACT_INSPECTION_PAGE_SIZE,
Math.max(1, Math.floor(requestedPageSize ?? DEFAULT_CONTACT_INSPECTION_PAGE_SIZE))
)
// service 调用前先收缩分页,保证 nextCursor 指向模型实际看过的最后一批结果,而不是截断后的尾部。
const maxChars = maxToolResultTokens * TOOL_RESULT_CHARS_PER_TOKEN
const budgeted = Math.max(1, Math.floor((maxChars - TOOL_RESULT_BASE_CHARS) / CONTACT_SESSION_ESTIMATED_CHARS))
return {
value: budgeted < requested ? budgeted : requestedPageSize,
limited: budgeted < requested,
}
}
function withContactToolResultBudgetReason(result: CrossChatContactSessionsResult): CrossChatContactSessionsResult {
return {
...result,
coverage: {
...result.coverage,
truncated: true,
truncatedReasons: [...new Set([...result.coverage.truncatedReasons, 'tool_result_budget' as const])],
},
}
}
function limitContactResultToBudget(
result: CrossChatContactSessionsResult,
maxToolResultTokens: number | undefined,
countTokens: (text: string) => number
): unknown {
if (!maxToolResultTokens || maxToolResultTokens <= 0) return result
if (countTokens(JSON.stringify(result)) <= maxToolResultTokens) return result
// details 保留完整结构供 UI 和日志追溯;这里只逐级压缩模型可见文本,并始终保留 coverage/continuation。
const compactLabels = {
...result,
contact: result.contact ? { ...result.contact, displayName: truncateModelLabel(result.contact.displayName) } : null,
sessions: result.sessions.map((session) => ({
...session,
sessionName: truncateModelLabel(session.sessionName),
memberName: truncateModelLabel(session.memberName),
})),
}
const coreFacts = {
algorithmVersion: result.algorithmVersion,
contact: compactLabels.contact,
appliedRange: result.appliedRange,
summary: result.summary,
sessions: result.sessions.map((session) => ({
sessionId: session.sessionId,
sessionName: truncateModelLabel(session.sessionName),
sessionType: session.sessionType,
platform: session.platform,
presence: session.presence,
ownMessageCount: session.ownMessageCount,
sessionMessageCount: session.sessionMessageCount,
firstOwnMessageTs: session.firstOwnMessageTs,
lastOwnMessageTs: session.lastOwnMessageTs,
activeDays: session.activeDays,
memberCount: session.memberCount,
sessionFirstMessageTs: session.sessionFirstMessageTs,
lastMessageTs: session.lastMessageTs,
})),
coverage: result.coverage,
}
const identityFacts = {
contact: result.contact
? {
contactKey: result.contact.contactKey,
displayName: truncateModelLabel(result.contact.displayName),
platform: result.contact.platform,
}
: null,
sessions: result.sessions.map((session) => ({
sessionId: session.sessionId,
sessionType: session.sessionType,
presence: session.presence,
ownMessageCount: session.ownMessageCount,
lastOwnMessageTs: session.lastOwnMessageTs,
})),
coverage: result.coverage,
}
const coverageOnly = { coverage: result.coverage }
const candidates: unknown[] = [compactLabels, coreFacts, identityFacts, coverageOnly]
return (
candidates.find((candidate) => countTokens(JSON.stringify(candidate)) <= maxToolResultTokens) ?? {
truncated: true,
truncatedReasons: ['tool_result_budget'],
}
)
}
function truncateModelLabel(value: string): string {
const chars = Array.from(value)
return chars.length > CONTACT_MODEL_LABEL_MAX_LENGTH
? `${chars.slice(0, CONTACT_MODEL_LABEL_MAX_LENGTH).join('')}…`
: value
}
async function preprocessBySession(
context: CrossChatToolExecutionContext,
messages: CrossChatMessageSource[]