You need to sign in before continuing.

Commit 9755a048ba56d7d91f1d92bf09489f034a76d818

Authored by yibo
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
web/src/components/GraphChatBar.vue
@@ -50,6 +50,7 @@ @@ -50,6 +50,7 @@
50 <script setup> 50 <script setup>
51 import { ref, computed, nextTick, watch, onMounted } from 'vue' 51 import { ref, computed, nextTick, watch, onMounted } from 'vue'
52 import { marked } from 'marked' 52 import { marked } from 'marked'
  53 +import { buildOutgoingMessage } from '@/composables/chatContextInjection.js'
53 54
54 const props = defineProps({ 55 const props = defineProps({
55 companyId: { type: String, required: true }, 56 companyId: { type: String, required: true },
@@ -83,11 +84,6 @@ const contextTooltip = computed(() =&gt; { @@ -83,11 +84,6 @@ const contextTooltip = computed(() =&gt; {
83 return `任务 ${c.taskId}《${c.taskTitle}》(${c.taskStatus}) · 目标 ${c.goalId}《${c.goalTitle}》` 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 function renderMarkdown(text) { 87 function renderMarkdown(text) {
92 try { return marked.parse(text || '', { breaks: true, gfm: true }) } 88 try { return marked.parse(text || '', { breaks: true, gfm: true }) }
93 catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') } 89 catch { return (text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>') }
@@ -112,12 +108,8 @@ async function handleSend() { @@ -112,12 +108,8 @@ async function handleSend() {
112 const text = inputText.value.trim() 108 const text = inputText.value.trim()
113 if (!text || sending.value) return 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 messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() }) 114 messages.value.push({ role: 'user', content: text, timestamp: new Date().toISOString() })
123 inputText.value = '' 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 +});