Commit 9755a048ba56d7d91f1d92bf09489f034a76d818
1 parent
d4266479
test(graph-console): 上下文注入逻辑抽成纯函数并补回归测试
GraphChatBar.vue 里"context 变了才拼说明、没变不重复"这条逻辑原来直接写在 <script setup> 里,脱不开组件测不了。照抄 agentChatEvents.js 的先例抽成 chatContextInjection.js 的纯函数(buildOutgoingMessage/contextKey),配一份 不用挂载组件就能跑的单测,锁住这条不变式。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YCd5WFjL1m31FaJXKx1j7Z
Showing
3 changed files
with
92 additions
and
11 deletions
web/src/components/GraphChatBar.vue
| ... | ... | @@ -50,6 +50,7 @@ |
| 50 | 50 | <script setup> |
| 51 | 51 | import { ref, computed, nextTick, watch, onMounted } from 'vue' |
| 52 | 52 | import { marked } from 'marked' |
| 53 | +import { buildOutgoingMessage } from '@/composables/chatContextInjection.js' | |
| 53 | 54 | |
| 54 | 55 | const props = defineProps({ |
| 55 | 56 | companyId: { type: String, required: true }, |
| ... | ... | @@ -83,11 +84,6 @@ const contextTooltip = computed(() => { |
| 83 | 84 | return `任务 ${c.taskId}《${c.taskTitle}》(${c.taskStatus}) · 目标 ${c.goalId}《${c.goalTitle}》` |
| 84 | 85 | }) |
| 85 | 86 | |
| 86 | -function contextKey(c) { | |
| 87 | - if (!c) return '' | |
| 88 | - return `${c.taskId || ''}|${c.goalId || ''}` | |
| 89 | -} | |
| 90 | - | |
| 91 | 87 | function renderMarkdown(text) { |
| 92 | 88 | try { return marked.parse(text || '', { breaks: true, gfm: true }) } |
| 93 | 89 | catch { return (text || '').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>') } |
| ... | ... | @@ -112,12 +108,8 @@ async function handleSend() { |
| 112 | 108 | const text = inputText.value.trim() |
| 113 | 109 | if (!text || sending.value) return |
| 114 | 110 | |
| 115 | - const key = contextKey(props.context) | |
| 116 | - let payload = text | |
| 117 | - if (props.context && key !== lastSentContextKey.value) { | |
| 118 | - payload = `[当前上下文] 任务 ${props.context.taskId}《${props.context.taskTitle}》(状态: ${props.context.taskStatus}) · 目标 ${props.context.goalId}《${props.context.goalTitle}》\n\n${text}` | |
| 119 | - lastSentContextKey.value = key | |
| 120 | - } | |
| 111 | + const { payload, nextKey } = buildOutgoingMessage(text, props.context, lastSentContextKey.value) | |
| 112 | + lastSentContextKey.value = nextKey | |
| 121 | 113 | |
| 122 | 114 | messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() }) |
| 123 | 115 | inputText.value = '' | ... | ... |
web/src/composables/chatContextInjection.js
0 → 100644
| 1 | +/** | |
| 2 | + * 从 GraphChatBar.vue 抽出来的纯逻辑:graph-console 底部聊天条要不要把"当前选中的 | |
| 3 | + * task/goal"拼进发给 agent 的消息里。 | |
| 4 | + * | |
| 5 | + * 抽成不依赖组件实例的纯函数,是为了能在不挂载整个 Vue 组件的前提下对它做回归测试 | |
| 6 | + * (照抄 agentChatEvents.js 的先例)。 | |
| 7 | + */ | |
| 8 | + | |
| 9 | +/** context 的身份 key —— 只看 taskId/goalId,title/status 变了不算"context 变了" */ | |
| 10 | +export function contextKey(context) { | |
| 11 | + if (!context) return ''; | |
| 12 | + return `${context.taskId || ''}|${context.goalId || ''}`; | |
| 13 | +} | |
| 14 | + | |
| 15 | +function contextLine(context) { | |
| 16 | + return `[当前上下文] 任务 ${context.taskId}《${context.taskTitle}》(状态: ${context.taskStatus}) · 目标 ${context.goalId}《${context.goalTitle}》`; | |
| 17 | +} | |
| 18 | + | |
| 19 | +/** | |
| 20 | + * 算出实际要发给后端的消息文本。 | |
| 21 | + * | |
| 22 | + * context 跟上次发送时不同才在文本前面拼一行说明,agent 不用用户手动复述在看哪个 | |
| 23 | + * 任务;没变化就原样发,避免每条消息都重复刷一遍同一句上下文。 | |
| 24 | + * | |
| 25 | + * @param {string} text 用户输入的原文(聊天气泡里展示的也是这个,不受影响) | |
| 26 | + * @param {object|null} context 当前选中的 task/goal,见 GraphConsole.vue 的 chatContext | |
| 27 | + * @param {string} lastSentKey 上一次实际发送时的 contextKey() | |
| 28 | + * @returns {{ payload: string, nextKey: string }} payload 是要 POST 的 message 字段; | |
| 29 | + * nextKey 供调用方回写 lastSentContextKey,没有 context 时透传 lastSentKey 不变 | |
| 30 | + */ | |
| 31 | +export function buildOutgoingMessage(text, context, lastSentKey) { | |
| 32 | + const key = contextKey(context); | |
| 33 | + if (context && key !== lastSentKey) { | |
| 34 | + return { payload: `${contextLine(context)}\n\n${text}`, nextKey: key }; | |
| 35 | + } | |
| 36 | + return { payload: text, nextKey: lastSentKey }; | |
| 37 | +} | ... | ... |
web/src/composables/chatContextInjection.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * 回归测试:graph-console 底部聊天条的上下文注入不变式—— | |
| 3 | + * context 变了才拼上下文行,没变就原样发,不能每条消息都刷一遍。 | |
| 4 | + */ | |
| 5 | +import { describe, it, expect } from 'vitest'; | |
| 6 | +import { contextKey, buildOutgoingMessage } from './chatContextInjection.js'; | |
| 7 | + | |
| 8 | +const ctxA = { taskId: 'T-1', taskTitle: '任务A', taskStatus: 'running', goalId: 'G-1', goalTitle: '目标A' }; | |
| 9 | +const ctxB = { taskId: 'T-2', taskTitle: '任务B', taskStatus: 'pending', goalId: 'G-2', goalTitle: '目标B' }; | |
| 10 | + | |
| 11 | +describe('chatContextInjection · buildOutgoingMessage', () => { | |
| 12 | + it('首次发送(lastSentKey 为空)带上下文时要拼上下文行', () => { | |
| 13 | + const { payload, nextKey } = buildOutgoingMessage('你好', ctxA, ''); | |
| 14 | + expect(payload).toContain('T-1'); | |
| 15 | + expect(payload).toContain('任务A'); | |
| 16 | + expect(payload.endsWith('你好')).toBe(true); | |
| 17 | + expect(nextKey).toBe(contextKey(ctxA)); | |
| 18 | + }); | |
| 19 | + | |
| 20 | + it('context 跟上次相同时不重复拼上下文行,原样发送用户文本', () => { | |
| 21 | + const first = buildOutgoingMessage('第一条', ctxA, ''); | |
| 22 | + const second = buildOutgoingMessage('第二条', ctxA, first.nextKey); | |
| 23 | + expect(second.payload).toBe('第二条'); | |
| 24 | + expect(second.nextKey).toBe(first.nextKey); | |
| 25 | + }); | |
| 26 | + | |
| 27 | + it('context 换了(task 或 goal 变化)要重新拼一次', () => { | |
| 28 | + const first = buildOutgoingMessage('第一条', ctxA, ''); | |
| 29 | + const second = buildOutgoingMessage('切到另一个任务了', ctxB, first.nextKey); | |
| 30 | + expect(second.payload).toContain('T-2'); | |
| 31 | + expect(second.payload).toContain('任务B'); | |
| 32 | + expect(second.nextKey).toBe(contextKey(ctxB)); | |
| 33 | + expect(second.nextKey).not.toBe(first.nextKey); | |
| 34 | + }); | |
| 35 | + | |
| 36 | + it('没有 context(没选中任务)时原样发送,不拼任何说明', () => { | |
| 37 | + const { payload, nextKey } = buildOutgoingMessage('随便聊聊', null, 'stale-key'); | |
| 38 | + expect(payload).toBe('随便聊聊'); | |
| 39 | + expect(nextKey).toBe('stale-key'); // 透传,不产生新 key | |
| 40 | + }); | |
| 41 | +}); | |
| 42 | + | |
| 43 | +describe('chatContextInjection · contextKey', () => { | |
| 44 | + it('null context 的 key 是空字符串', () => { | |
| 45 | + expect(contextKey(null)).toBe(''); | |
| 46 | + }); | |
| 47 | + | |
| 48 | + it('taskId/goalId 相同则 key 相同,跟 title/status 无关', () => { | |
| 49 | + const same = { ...ctxA, taskTitle: '改了标题', taskStatus: 'completed' }; | |
| 50 | + expect(contextKey(same)).toBe(contextKey(ctxA)); | |
| 51 | + }); | |
| 52 | +}); | ... | ... |