feat(h5): web 联网能力、实时查询路由与 session Finish 对齐
- 新增 web 能力并挂载 platform/web(web_search/fetch_url) - 实时查询强制 web skill,router fallback 与 await session Finish - Session Broker 覆盖率/指标、stream replay 与相关单测/E2E 脚本 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+320
-3
@@ -10,10 +10,17 @@ import {
|
||||
createChatIntentRouter,
|
||||
createManagedChatIntentRouter,
|
||||
isNormalizedRouterDecisionEnabled,
|
||||
isNormalizedRouterDecisionShadow,
|
||||
resolveGatewayAgentSessionId,
|
||||
resolveLegacyRouteFromClassification,
|
||||
resolveNormalizedRouterDecisionMode,
|
||||
logRouterDecisionShadow,
|
||||
ROUTER_DECISION_ROUTE,
|
||||
ROUTER_DECISION_MODE,
|
||||
ROUTER_DECISION_SESSION_HINT,
|
||||
isRealtimeInfoQuestion,
|
||||
coerceRealtimeWebSkill,
|
||||
resolveChatIntentRouterPolicy,
|
||||
} from './chat-intent-router.mjs';
|
||||
|
||||
test('classifyWithRules routes greetings to direct chat', () => {
|
||||
@@ -434,7 +441,11 @@ test('createManagedChatIntentRouter hot-loads admin config and stays closed by d
|
||||
states.shift();
|
||||
assert.equal(await router.isEnabled(), true);
|
||||
const result = await router.classify({
|
||||
userMessage: { role: 'user', content: [{ type: 'text', text: '你好' }] },
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我概括一下这段材料的要点' }],
|
||||
metadata: { displayText: '帮我概括一下这段材料的要点' },
|
||||
},
|
||||
});
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
|
||||
assert.equal(llmCalls[0].providerKeyId, 'key-router');
|
||||
@@ -485,8 +496,8 @@ test('createChatIntentRouter falls back to agent when LLM fails', async () => {
|
||||
const result = await router.classify({
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我查一下今天的 AI 新闻' }],
|
||||
metadata: { displayText: '帮我查一下今天的 AI 新闻' },
|
||||
content: [{ type: 'text', text: '帮我概括一下这段材料的要点' }],
|
||||
metadata: { displayText: '帮我概括一下这段材料的要点' },
|
||||
},
|
||||
grantedSkills: ['web'],
|
||||
});
|
||||
@@ -495,6 +506,32 @@ test('createChatIntentRouter falls back to agent when LLM fails', async () => {
|
||||
assert.equal(result.source, 'fallback');
|
||||
});
|
||||
|
||||
test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => {
|
||||
let llmCalls = 0;
|
||||
const router = createChatIntentRouter({
|
||||
enabled: true,
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
llmCalls += 1;
|
||||
return { ok: false, message: 'router model unavailable' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await router.classify({
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我查一下今天的 AI 新闻' }],
|
||||
metadata: { displayText: '帮我查一下今天的 AI 新闻' },
|
||||
},
|
||||
grantedSkills: ['web'],
|
||||
});
|
||||
|
||||
assert.equal(llmCalls, 0);
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.equal(result.source, 'rule');
|
||||
});
|
||||
|
||||
test('applyAgentOrchestrationToUserMessage preserves displayText and adds task envelope', () => {
|
||||
const enriched = applyAgentOrchestrationToUserMessage(
|
||||
{
|
||||
@@ -604,6 +641,286 @@ test('resolveLegacyRouteFromClassification prefers decision when flag enabled',
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveGatewayAgentSessionId honors session_hint without breaking hard rules', () => {
|
||||
const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '1';
|
||||
try {
|
||||
assert.equal(
|
||||
resolveGatewayAgentSessionId({
|
||||
agentSessionId: 'sess-1',
|
||||
classification: {
|
||||
decision: { session_hint: ROUTER_DECISION_SESSION_HINT.REUSE },
|
||||
},
|
||||
}),
|
||||
'sess-1',
|
||||
);
|
||||
assert.equal(
|
||||
resolveGatewayAgentSessionId({
|
||||
agentSessionId: 'sess-1',
|
||||
classification: {
|
||||
decision: { session_hint: ROUTER_DECISION_SESSION_HINT.NEW },
|
||||
},
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveGatewayAgentSessionId({
|
||||
agentSessionId: 'h5direct_abc',
|
||||
classification: {
|
||||
decision: { session_hint: ROUTER_DECISION_SESSION_HINT.NEW },
|
||||
},
|
||||
}),
|
||||
'h5direct_abc',
|
||||
);
|
||||
assert.equal(
|
||||
resolveGatewayAgentSessionId({
|
||||
agentSessionId: 'sess-1',
|
||||
forceDeepReasoning: true,
|
||||
classification: {
|
||||
decision: { session_hint: ROUTER_DECISION_SESSION_HINT.NEW },
|
||||
},
|
||||
}),
|
||||
'sess-1',
|
||||
);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('resolveNormalizedRouterDecisionMode supports off, shadow, and on', () => {
|
||||
const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
try {
|
||||
delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
assert.equal(resolveNormalizedRouterDecisionMode(), 'off');
|
||||
process.env.MEMIND_ROUTER_NORMALIZED_DECISION = 'shadow';
|
||||
assert.equal(resolveNormalizedRouterDecisionMode(), 'shadow');
|
||||
assert.equal(isNormalizedRouterDecisionShadow(), true);
|
||||
assert.equal(isNormalizedRouterDecisionEnabled(), false);
|
||||
process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '1';
|
||||
assert.equal(resolveNormalizedRouterDecisionMode(), 'on');
|
||||
assert.equal(isNormalizedRouterDecisionEnabled(), true);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('shadow mode keeps legacy route for gateway decisions', () => {
|
||||
const classification = {
|
||||
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||||
decision: { route: ROUTER_DECISION_ROUTE.AGENT },
|
||||
};
|
||||
const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
process.env.MEMIND_ROUTER_NORMALIZED_DECISION = 'shadow';
|
||||
try {
|
||||
assert.equal(resolveLegacyRouteFromClassification(classification), CHAT_INTENT_ROUTE.DIRECT_CHAT);
|
||||
assert.equal(isNormalizedRouterDecisionEnabled(), false);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('classifyWithRules fast-paths bedtime story when llm router is enabled', () => {
|
||||
const result = classifyWithRules({
|
||||
text: '讲一个睡前故事吧',
|
||||
llmRouterEnabled: true,
|
||||
sessionId: '20260706_2',
|
||||
sessionMessageCount: 3,
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '讲一个睡前故事吧' }],
|
||||
metadata: { displayText: '讲一个睡前故事吧' },
|
||||
},
|
||||
});
|
||||
assert.equal(result?.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
|
||||
assert.equal(result?.source, 'rule');
|
||||
assert.equal(result?.decision?.route, ROUTER_DECISION_ROUTE.CHAT);
|
||||
});
|
||||
|
||||
test('classifyWithRules fast-paths world cup standings to agent orchestration', () => {
|
||||
const text = '世界杯现在赛况如何';
|
||||
assert.equal(isRealtimeInfoQuestion(text), true);
|
||||
const result = classifyWithRules({
|
||||
text,
|
||||
llmRouterEnabled: true,
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
metadata: { displayText: text },
|
||||
},
|
||||
});
|
||||
assert.equal(result?.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.equal(result?.source, 'rule');
|
||||
assert.equal(result?.suggestedSkill, 'web');
|
||||
assert.match(result?.reason, /实时/);
|
||||
});
|
||||
|
||||
test('createChatIntentRouter fast-paths world cup without calling router LLM', async () => {
|
||||
let llmCalls = 0;
|
||||
const router = createChatIntentRouter({
|
||||
enabled: true,
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
llmCalls += 1;
|
||||
throw new Error('router should not be called');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await router.classify({
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '世界杯现在赛况如何' }],
|
||||
metadata: { displayText: '世界杯现在赛况如何' },
|
||||
},
|
||||
grantedSkills: ['web'],
|
||||
});
|
||||
|
||||
assert.equal(llmCalls, 0);
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.equal(result.source, 'rule');
|
||||
});
|
||||
|
||||
test('resolveChatIntentRouterPolicy coerces direct_chat fallback to agent', () => {
|
||||
const policy = resolveChatIntentRouterPolicy({
|
||||
env: {
|
||||
MEMIND_CHAT_ROUTER_FALLBACK_ROUTE: 'direct_chat',
|
||||
},
|
||||
});
|
||||
assert.equal(policy.fallbackRoute, CHAT_INTENT_ROUTE.AGENT);
|
||||
});
|
||||
|
||||
test('coerceRealtimeWebSkill replaces search with web for world cup query', () => {
|
||||
const coerced = coerceRealtimeWebSkill(
|
||||
{
|
||||
route: CHAT_INTENT_ROUTE.AGENT,
|
||||
confidence: 0.9,
|
||||
reason: '用户询问实时赛况,需要搜索最新信息',
|
||||
suggestedSkill: 'search',
|
||||
source: 'llm',
|
||||
},
|
||||
'世界杯现在赛况如何',
|
||||
{ grantedSkills: ['web', 'search'] },
|
||||
);
|
||||
assert.equal(coerced.suggestedSkill, 'web');
|
||||
assert.match(coerced.agentBrief, /web_search/);
|
||||
});
|
||||
|
||||
test('applyAgentOrchestrationToUserMessage uses web news prompt for realtime query', () => {
|
||||
const enriched = applyAgentOrchestrationToUserMessage(
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '世界杯现在赛况如何' }],
|
||||
metadata: { displayText: '世界杯现在赛况如何', userVisible: true },
|
||||
},
|
||||
coerceRealtimeWebSkill(
|
||||
{
|
||||
route: CHAT_INTENT_ROUTE.AGENT,
|
||||
confidence: 0.9,
|
||||
reason: '需要搜索实时资料',
|
||||
suggestedSkill: 'web',
|
||||
source: 'rule',
|
||||
},
|
||||
'世界杯现在赛况如何',
|
||||
{ grantedSkills: ['web'] },
|
||||
),
|
||||
{ grantedSkills: ['web'] },
|
||||
);
|
||||
assert.match(enriched.content[0].text, /请使用 web 技能:先搜索今天\/最新相关的新闻与热点/);
|
||||
assert.doesNotMatch(enriched.content[0].text, /search 技能:帮我在工作区中查找代码或文件/);
|
||||
assert.match(enriched.content[0].text, /web_search/);
|
||||
});
|
||||
|
||||
test('createChatIntentRouter coerces LLM search route to web for realtime query', async () => {
|
||||
const router = createChatIntentRouter({
|
||||
enabled: true,
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
return {
|
||||
ok: true,
|
||||
reply: JSON.stringify({
|
||||
route: 'agent_orchestration',
|
||||
confidence: 0.92,
|
||||
reason: '用户询问实时赛况,需要搜索最新信息',
|
||||
suggested_skill: 'search',
|
||||
agent_brief: '搜索赛况',
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await router.classify({
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '世界杯现在赛况如何' }],
|
||||
metadata: { displayText: '世界杯现在赛况如何' },
|
||||
},
|
||||
grantedSkills: ['web', 'search'],
|
||||
});
|
||||
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.equal(result.suggestedSkill, 'web');
|
||||
assert.match(result.agentBrief, /web_search/);
|
||||
});
|
||||
|
||||
test('createChatIntentRouter timeout fallback prefers agent even when policy requests direct_chat', async () => {
|
||||
const router = createChatIntentRouter({
|
||||
enabled: true,
|
||||
timeoutMs: 5,
|
||||
fallbackRoute: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||||
llmProviderService: {
|
||||
async createChatCompletion() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
return { ok: true, reply: '{"route":"direct_chat","confidence":0.9,"reason":"x","suggested_skill":null,"agent_brief":""}' };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await router.classify({
|
||||
userMessage: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我整理一下这段材料' }],
|
||||
metadata: { displayText: '帮我整理一下这段材料' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
|
||||
assert.equal(result.source, 'fallback');
|
||||
});
|
||||
|
||||
test('logRouterDecisionShadow emits payload only in shadow mode', () => {
|
||||
const previous = process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
const lines = [];
|
||||
const logger = { log: (line) => lines.push(line) };
|
||||
const classification = {
|
||||
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
|
||||
source: 'llm',
|
||||
reason: '纯文字',
|
||||
decision: {
|
||||
route: ROUTER_DECISION_ROUTE.AGENT,
|
||||
mode: ROUTER_DECISION_MODE.SSE,
|
||||
session_hint: ROUTER_DECISION_SESSION_HINT.REUSE,
|
||||
flags: [],
|
||||
},
|
||||
};
|
||||
try {
|
||||
process.env.MEMIND_ROUTER_NORMALIZED_DECISION = '0';
|
||||
logRouterDecisionShadow(classification, { logger, requestId: 'req-1' });
|
||||
assert.equal(lines.length, 0);
|
||||
process.env.MEMIND_ROUTER_NORMALIZED_DECISION = 'shadow';
|
||||
logRouterDecisionShadow(classification, { logger, requestId: 'req-1' });
|
||||
assert.equal(lines.length, 1);
|
||||
assert.match(lines[0], /\[router-shadow\]/);
|
||||
assert.match(lines[0], /wouldChangeRoute":true/);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.MEMIND_ROUTER_NORMALIZED_DECISION;
|
||||
else process.env.MEMIND_ROUTER_NORMALIZED_DECISION = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('createChatIntentRouter accepts chat and agent route synonyms from llm', async () => {
|
||||
const router = createChatIntentRouter({
|
||||
enabled: true,
|
||||
|
||||
Reference in New Issue
Block a user