import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import crypto from 'node:crypto'; import os from 'node:os'; import path from 'node:path'; import {createApplication} from '../src/core.mjs'; import {attachWorkshopHandlers} from '../src/workshop.mjs'; import {registerAgentProfile,registerParticipantProfile,registerChannelParticipant,readAgentProfileByKey,readParticipantProfile} from '../../skills/ksf-workshop-runtime/scripts/ksf-identity.mjs'; import {runWorkshop} from '../../skills/ksf-workshop-runtime/scripts/ksf-workshop.mjs'; import {runFullChain,validateContext} from '../../scripts/workshop-full-chain.mjs'; import {runDedupAcceptance,validateDedupContext,dedupTask,TASK_KEY} from '../../scripts/workshop-dedup-acceptance.mjs'; import {runFirstLiveImport,validateLiveContext} from '../../scripts/workshop-first-live-import.mjs'; const data=JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json',import.meta.url))); test('真实S1小结绑定两份官网来源,逐字引文支持公开课堂展示,不制造机会项目或正式确认',async()=>{ const f=setup(),bundle=JSON.parse(fs.readFileSync(new URL('./fixtures/first-live-sources-20261102.json',import.meta.url))); const task=JSON.parse(fs.readFileSync(new URL('./fixtures/first-live-S1-20261102.json',import.meta.url))); assert.equal(task.sessionId,null);assert.equal(task.stageCode,'S1');assert.equal(task.sourceIds.length,2); const ids=[]; for(const b of bundle.batches){const source=b.task.source;const r=await f.call('ksfRegisterSource',{requestId:b.key+':s1-source',source,contentHash:crypto.createHash('sha256').update(source.rawText).digest('hex')},{...actor,scopes:[...actor.scopes,'source:write']});ids.push(r.sourceId);} const ctx=await f.call('ksfStartWorkshopSession',{...start,requestId:'live-s1-start',workshopKey:bundle.taskKey,dataMode:'live'}); task.sessionId=ctx.sessionId;task.sourceIds=ids; Object.values(task.report.evidence).forEach((entry,i)=>entry.sourceId=ids[i]); const receipt=await f.call('ksfSaveWorkshopTask',{...task,requestId:'live-s1-save'}); assert.equal(receipt.dataMode,'live');assert.equal(receipt.stageCode,'S1');assert.equal(receipt.boardVisible,true);assert.equal(receipt.formalReportPublished,false);assert.equal(receipt.employeeConfirmed,false); const a=await f.store.get('KfArtifact',receipt.artifactId); assert.equal(a.evidenceCount,2);assert.equal(a.sources.length,2);assert.deepEqual(a.meta.workshop.sourceIds,ids); assert.equal(a.dashboardState,'workshop');assert.equal(a.report.chapters[0].sections.find(s=>s.title==='已掌握的9个口味条目').data.rows.length,9); assert.equal((await f.store.list('KfOpportunity')).length,0);assert.equal((await f.store.list('KfProject')).length,0); const pub=await f.call('ksfGetPublicBoard',{},null);assert.ok(pub.artifact.some(r=>r.objectId===a.objectId)); }); test('真实官网归档资料入库:9个口味和逐字证据,复用品类,原文来源精确回读,不创建课堂报告',async t=>{ const f=await dedupFixture(t); const bundle=JSON.parse(fs.readFileSync(new URL('./fixtures/first-live-sources-20261102.json',import.meta.url))); const context={version:'ksf.first-live-import.v1',taskKey:bundle.taskKey,agentProfileKey:f.context.agentProfileKey}; const r=await runFirstLiveImport({...f,context,bundle}); assert.equal(r.ok,true);assert.equal(r.productEntryCount,9);assert.equal(r.evidenceCount,9); for(const [name,count]of [['KfSource',2],['KfCategory',1],['KfProduct',9],['KfEvidence',9],['KfArtifact',0],['KfSession',0]])assert.equal((await f.store.list(name)).length,count,name); for(const e of await f.store.list('KfEvidence')){const s=await f.store.get('KfSource',e.sourceRef.objectId);assert.ok(s.rawText.includes(e.quote));assert.equal(e.evidenceKind,'manufacturer_product_statement');} assert.ok((await f.store.list('KfProduct')).every(p=>p.observedRetailPrice===undefined&&p.netWeightG===undefined)); assert.equal(r.formalReportPublished,false); assert.equal(r.imports.reduce((n,b)=>n+b.counts.created,0),19); assert.equal(r.imports.reduce((n,b)=>n+b.counts.unchanged,0),1); }); test('新批次直接引用已有分类,保留产品证据依赖,不再次提交分类或修改旧范围说明',async t=>{ const f=await dedupFixture(t); const bundle=JSON.parse(fs.readFileSync(new URL('./fixtures/first-live-sources-20261102.json',import.meta.url))); const first=structuredClone(bundle.batches[0].task); first.records=first.records.filter(r=>r.itemKey==='category'); const seeded=await f.invoke('workshop-import',first,'existing-category-seed'); const categoryId=seeded.items[0].objectId,categoryBefore=await f.store.get('KfCategory',categoryId); const next=structuredClone(bundle.batches[1].task); next.records=next.records.filter(r=>['product-1','evidence-1'].includes(r.itemKey)); const product=next.records.find(r=>r.itemKey==='product-1'); product.data.categoryRef={__type:'Pointer',className:'KfCategory',objectId:categoryId}; product.dependsOn=product.dependsOn.filter(k=>k!=='category'); const original=structuredClone(next); const receipt=await f.invoke('workshop-import',next,'existing-category-recovery'); assert.equal(receipt.status,'succeeded');assert.equal(receipt.items.length,2); assert.equal(receipt.counts.created,2); assert.equal((await f.store.list('KfCategory')).length,1); assert.deepEqual(await f.store.get('KfCategory',categoryId),categoryBefore); const savedProduct=await f.store.get('KfProduct',receipt.items.find(i=>i.className==='KfProduct').objectId); const savedEvidence=await f.store.get('KfEvidence',receipt.items.find(i=>i.className==='KfEvidence').objectId); assert.equal(savedProduct.categoryRef.objectId,categoryId); assert.equal(savedProduct.sourceRef.objectId,receipt.sourceId); assert.equal(savedEvidence.productRef.objectId,savedProduct.objectId); assert.ok(next.source.rawText.includes(savedEvidence.quote)); assert.deepEqual(next,original,'input is retained unchanged by the runner'); const recoveryJobs=(await f.store.list('KfImportJob')).filter(j=>j.inputSourceRef.objectId===receipt.sourceId); assert.equal(recoveryJobs.length,2); assert.ok((await f.store.list('KfImportItem')).filter(i=>recoveryJobs.some(j=>i.jobRef.objectId===j.objectId)).every(i=>i.targetClass!=='KfCategory')); }); test('真实批次来源/引文损坏写入前停止,拒绝把样例键或秘密塞入上下文',async t=>{ const f=await dedupFixture(t),bundle=JSON.parse(fs.readFileSync(new URL('./fixtures/first-live-sources-20261102.json',import.meta.url))); const context={version:'ksf.first-live-import.v1',taskKey:bundle.taskKey,agentProfileKey:f.context.agentProfileKey}; assert.throws(()=>validateLiveContext({...context,token:'forbidden'}),{code:'INVALID_LIVE_CONTEXT'}); bundle.batches[1].task.records.find(r=>r.className==='KfEvidence').data.quote='not actually on page'; await assert.rejects(()=>runFirstLiveImport({...f,context,bundle}),{code:'LIVE_QUOTE_MISMATCH'}); assert.equal(f.calls.filter(r=>r.command==='workshop-import').length,0); assert.equal((await f.store.list('KfSource')).length,0); }); async function dedupFixture(t){ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-dedup-acceptance-')); t.after(()=>fs.rmSync(root,{recursive:true})); const options={profileDir:path.join(root,'agents')}; const registered=registerAgentProfile({name:'查重测试助手',role:'workshop',identityBasis:'runtime_config',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',accountId:'fixture-dedup-bot'}},options).profile; const agentProfile=readAgentProfileByKey(registered.key,options.profileDir),fixture=setup(),calls=[],events=[]; const context={version:'ksf.dedup-acceptance.v1',taskKey:TASK_KEY,agentProfileKey:agentProfile.key}; const invoke=async(command,input,requestId)=>{calls.push({command,input:structuredClone(input),requestId});return runWorkshop(command,input,{requestId,agentProfile,call:(name,params)=>fixture.call(name,params,{...actor,scopes:[...actor.scopes,'source:write']})});}; return {...fixture,context,invoke,calls,events,checkpoint:e=>events.push(e)}; } test('专用查重执行器跨批次复用同一来源原料,不创建工作坊会话;保留两批审计',async t=>{ const f=await dedupFixture(t),r=await runDedupAcceptance(f); assert.equal(r.ok,true);assert.equal(r.rounds.a.created,1);assert.equal(r.rounds.b.created,0);assert.equal(r.rounds.b.unchanged,1); assert.equal(r.rounds.a.sourceId,r.rounds.b.sourceId);assert.equal(r.rounds.a.materialId,r.rounds.b.materialId); assert.notEqual(r.rounds.a.jobId,r.rounds.b.jobId); for(const n of ['KfSource','KfMaterial'])assert.equal((await f.store.list(n)).length,1,n); for(const n of ['KfSession','KfProject','KfArtifact','KfConcept','_User'])assert.equal((await f.store.list(n)).length,0,n); assert.equal((await f.store.list('KfImportJob')).length,2); assert.equal((await f.store.list('KfImportItem')).length,2); const imports=f.calls.filter(x=>x.command==='workshop-import');assert.equal(imports.length,2); assert.notEqual(imports[0].requestId,imports[1].requestId); assert.notEqual(imports[0].input.records[0].externalKey,imports[1].input.records[0].externalKey); assert.deepEqual(imports[0].input.source,imports[1].input.source);assert.deepEqual(imports[0].input.records[0].data,imports[1].input.records[0].data); const before=f.calls.length; await assert.rejects(()=>runDedupAcceptance(f),{code:'DEDUP_HISTORY_EXISTS'}); assert.equal(f.calls.slice(before).filter(x=>x.command==='workshop-import').length,0); }); test('查重第二次导入响应丢失时停住,保留首批编号和在途记录,不重试',async t=>{ const f=await dedupFixture(t),invoke=f.invoke; await assert.rejects(()=>runDedupAcceptance({...f,invoke:async(...args)=>{ const r=await invoke(...args); if(args[0]==='workshop-import'&&args[2].endsWith(':batch-b'))throw Object.assign(Error('timeout after commit'),{code:'REMOTE_REQUEST_UNCERTAIN'}); return r; }}),{code:'REMOTE_REQUEST_UNCERTAIN'}); assert.equal(f.calls.filter(x=>x.command==='workshop-import').length,2); assert.equal(f.events.at(-1).status,'in-flight');assert.ok(f.events.at(-1).rounds.a.materialId); assert.equal((await f.store.list('KfMaterial')).length,1); assert.equal((await f.store.list('KfImportJob')).length,2); }); test('查重预检编号不一致时禁止第二批写入;上下文拒绝密钥和旧任务键',async t=>{ const f=await dedupFixture(t),invoke=f.invoke; assert.throws(()=>validateDedupContext({...f.context,token:'forbidden'}),{code:'INVALID_DEDUP_CONTEXT'}); assert.throws(()=>validateDedupContext({...f.context,taskKey:'KSF-企微实测-20261101-K'}),{code:'INVALID_DEDUP_CONTEXT'}); assert.throws(()=>validateDedupContext({...f.context,agentProfileKey:null}),{code:'TRUSTED_AGENT_PROFILE_REQUIRED'}); await assert.rejects(()=>runDedupAcceptance({...f,invoke:async(...args)=>{ const r=await invoke(...args);if(args[2].endsWith(':preflight-b'))r.items[0].objectId='wrong-id';return r; }}),{code:'DEDUP_REUSE_PREFLIGHT_MISMATCH'}); assert.equal(f.calls.filter(x=>x.command==='workshop-import').length,1); assert.deepEqual(dedupTask('a').source,dedupTask('b').source); }); function memory(){let db={},seq=0;const copy=structuredClone;return{ async transaction(fn){const prior=copy(db);try{return await fn(this);}catch(e){db=prior;throw e;}},async lock(){}, async get(n,id){return copy(db[n]?.find(x=>x.objectId===id)||null);}, async list(n,{where={},afterId,limit=200}={}){return copy((db[n]||[]).filter(r=>Object.entries(where).every(([k,v])=>JSON.stringify(r[k]??null)===JSON.stringify(v))).filter(r=>!afterId||r.objectId>afterId).sort((a,b)=>a.objectId.localeCompare(b.objectId)).slice(0,limit));}, async create(n,d){for(const k of Object.keys(d))assert.ok(data.tables[n][k],n+'.'+k+' must exist in deployed schema');const r={...copy(d),objectId:'t'+String(++seq).padStart(9,'0'),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};(db[n]??=[]).push(r);return copy(r);}, async update(n,id,d){for(const k of Object.keys(d))assert.ok(data.tables[n][k],n+'.'+k);const r=db[n]?.find(x=>x.objectId===id);assert.ok(r);Object.assign(r,copy(d));return copy(r);} };} const actor={id:'service-a',kind:'service',scopes:['ingest:write','data:read'],projectIds:['*']}; const other={...actor,id:'service-b'}; const start={requestId:'s1',workshopKey:'workshop-test',participant:{key:'user-1',name:'演练人员',identityBasis:'participant_registration'},agent:{name:'王刚001'},conversation:{key:'conversation-1',channel:'wecom'},groupId:'G1',dataMode:'sample'}; function setup(){const store=memory(),app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'test-signing-only'},files:{}});attachWorkshopHandlers(app,{store,data});return{app,store,call:async(n,p,a=actor)=>(await app.dispatch(n,JSON.parse(JSON.stringify(p)),a)).data};} const task=(sid,overrides={})=>({requestId:'task-1',sessionId:sid,taskKey:'task-s3',version:1,stageCode:'S3',title:'待验证的拌面方向',artifactType:'opportunity_card',category:'拌面',rawText:'技术测试原文,没有市场证据。',sourceIds:[],pendingQuestions:['需要消费者资料'],report:{executive:['演示问题拆解'],chapters:[{id:'c1',title:'待验证',stage:'S3',sections:[{kind:'text',text:'尚无消费者研究依据'}]}]},opportunity:{key:'opportunity-1',title:'拌面方向',demandSummary:'技术测试,需补原文',targetAudience:'待研究人群',usageScenario:'待研究场景',fusionDefinition:'赛道×用户×场景,全部待验证'},...overrides}); async function seeded(){const f=setup();const ctx=await f.call('ksfStartWorkshopSession',start);const saved=await f.call('ksfSaveWorkshopTask',task(ctx.sessionId));const proj=await f.call('ksfCreateProjectFromOpportunity',{requestId:'p1',sessionId:ctx.sessionId,opportunityId:saved.opportunityId,projectKey:'p1',title:'演练项目'});return{...f,ctx,saved,proj};} test('记录员工与王刚001,重放不新建会话,不建立假员工账号',async()=>{const{call,store}=setup();const a=await call('ksfStartWorkshopSession',start),b=await call('ksfStartWorkshopSession',start);assert.equal(a.sessionId,b.sessionId);assert.equal((await store.list('KfSession')).length,1);assert.equal((await store.list('_User')).length,0);const row=await store.get('KfSession',a.sessionId);assert.equal(row.studentName,'演练人员');assert.equal(row.meta.attribution.agentName,'王刚001');assert.equal(row.meta.attribution.agentKeyOrigin,'name_only');assert.notEqual(row.meta.attribution.participantKey,start.participant.key);}); test('aibot 无姓名无企业ID自动登记后保存归属,机器人作用域不会进入公开看板',async t=>{ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-aibot-server-'));t.after(()=>{assert.equal(path.dirname(root),fs.realpathSync(os.tmpdir()));assert.ok(path.basename(root).startsWith('ksf-aibot-server-'));fs.rmSync(root,{recursive:true});}); const opts={profileDir:path.join(root,'agents'),participantDir:path.join(root,'participants')},botId='private-aibot',senderId='private-sender'; const registered=registerAgentProfile({name:'王刚001',role:'workshop',identityBasis:'runtime_config',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',accountId:botId}},opts).profile; const agentProfile=readAgentProfileByKey(registered.key,opts.profileDir),employee=registerChannelParticipant({version:'ksf.wecom-aibot-context.v1',botId,senderId,chatId:null,chatType:'single',messageId:'message-1'},{...opts,agentProfile}); const participantProfile=readParticipantProfile(employee.profile.key,opts.participantDir),{call,store}=setup(); const ctx=await runWorkshop('workshop-start',{workshopKey:'aibot-test',participant:{profileKey:participantProfile.key},conversation:{channel:'wecom',key:senderId},dataMode:'sample'},{requestId:'aibot-start',call,agentProfile,participantProfile}); const row=await store.get('KfSession',ctx.sessionId);assert.equal(row.meta.attribution.participantAccount.tenantId,null);assert.equal(row.meta.attribution.participantAccount.accountId,senderId);assert.equal(row.meta.attribution.participantAccount.scopeId,botId);assert.equal(row.userRef,undefined);assert.match(row.studentName,/员工待绑定/); const saved=await runWorkshop('workshop-save',task(ctx.sessionId,{requestId:undefined}),{requestId:'aibot-save',call,agentProfile});assert.deepEqual((await store.get('KfArtifact',saved.artifactId)).meta.attribution,row.meta.attribution); const pub=await call('ksfGetPublicBoard',{},null);assert.ok(!JSON.stringify(pub).includes('private-'));assert.match(pub.session[0].studentName,/员工待绑定/);assert.equal((await store.list('_User')).length,0); const direct={...start,requestId:'wrong-scope',agent:{...agentProfile},participant:{key:participantProfile.key,name:participantProfile.name,identityBasis:'channel_context',account:{...participantProfile.account,scopeId:'other-bot'}}}; delete direct.agent.version;delete direct.agent.registeredAt; await assert.rejects(()=>call('ksfStartWorkshopSession',direct),{code:'IDENTITY_CONFLICT'});assert.equal((await store.list('KfSession')).length,1); }); test('账号归属保存至私有元数据并继承到报告项目,公开看板不泄露;登记不等于认证',async()=>{ const{call,store}=setup();const agentAccount={platform:'wecom',tenantId:'private-tenant',accountId:'private-bot',accountName:'private-account-name',runtimeAgentId:'private-runtime',wecomAppAgentId:'private-app-agent'}; const participantAccount={platform:'wecom',tenantId:'private-tenant',accountId:'private-person',employeeNumber:'private-employee'}; const input={...start,agent:{name:'王刚001',key:'agent-registration:fixture',identityBasis:'operator_registration',account:agentAccount,role:'workshop',runtimePlatform:'hermes',channel:'wecom',collectedAt:'2026-11-02T01:00:00.000Z'},participant:{...start.participant,account:participantAccount}}; const ctx=await call('ksfStartWorkshopSession',input),row=await store.get('KfSession',ctx.sessionId);assert.deepEqual(row.meta.attribution.agentAccount,agentAccount);assert.equal(row.meta.attribution.participantAccount.accountName,null);assert.equal(row.meta.attribution.agentAccountVerified,false);assert.equal(row.meta.attribution.agentKeyOrigin,'operator_registration');assert.equal(row.userRef,undefined); assert.equal(row.meta.attribution.agentRuntimePlatform,'hermes');assert.equal(row.meta.attribution.agentChannel,'wecom');assert.equal(row.meta.attribution.agentCollectedAt,input.agent.collectedAt);assert.equal(row.meta.attribution.agentRole,'workshop'); const saved=await call('ksfSaveWorkshopTask',task(ctx.sessionId));const proj=await call('ksfCreateProjectFromOpportunity',{requestId:'account-project',sessionId:ctx.sessionId,opportunityId:saved.opportunityId,projectKey:'account-project',title:'归属检查'}); for(const[n,id]of [['KfArtifact',saved.artifactId],['KfSource',saved.sourceId],['KfProject',proj.projectId]])assert.deepEqual((await store.get(n,id)).meta.attribution,row.meta.attribution); assert.equal((await store.get('KfOpportunity',saved.opportunityId)).sessionRef.objectId,ctx.sessionId);assert.equal(saved.dataMode,'sample'); const pub=await call('ksfGetPublicBoard',{},null);assert.ok(!JSON.stringify(pub).includes('private-'));assert.equal(pub.artifact[0].agentName,'王刚001');assert.equal((await store.list('_User')).length,0); await assert.rejects(()=>call('ksfStartWorkshopSession',{...input,requestId:'changed-account',agent:{...input.agent,account:{...agentAccount,accountId:'changed'}}}),{code:'IDENTITY_CONFLICT'}); await assert.rejects(()=>call('ksfStartWorkshopSession',{...input,requestId:'dropped-account',agent:{...input.agent,account:undefined}}),{code:'IDENTITY_CONFLICT'}); await assert.rejects(()=>call('ksfStartWorkshopSession',{...input,requestId:'credential-field',agent:{...input.agent,account:{...agentAccount,token:'fixture'}}}),{code:'VALIDATION_FAILED'}); await assert.rejects(()=>call('ksfStartWorkshopSession',{...input,requestId:'wrong-channel',agent:{...input.agent,channel:'other'}}),{code:'IDENTITY_CONFLICT'}); await assert.rejects(()=>call('ksfStartWorkshopSession',{...input,requestId:'wrong-role',agent:{...input.agent,role:'operations'}}),{code:'VALIDATION_FAILED'}); await assert.rejects(()=>call('ksfStartWorkshopSession',{...input,requestId:'changed-runtime',agent:{...input.agent,runtimePlatform:'other'}}),{code:'IDENTITY_CONFLICT'}); await assert.rejects(()=>call('ksfStartWorkshopSession',{...start,requestId:'bad-user',participant:{...start.participant,userId:'wecom-is-not-user-object-id'}}),{code:'REFERENCE_NOT_FOUND'}); }); test('同名员工不同渠道身份不串会话,改参数重用 requestId 被拒',async()=>{const{call}=setup();const a=await call('ksfStartWorkshopSession',start);const b=await call('ksfStartWorkshopSession',{...start,requestId:'s2',participant:{...start.participant,key:'user-2'}});assert.notEqual(a.sessionId,b.sessionId);await assert.rejects(()=>call('ksfStartWorkshopSession',{...start,groupId:'G2'}),{code:'IDEMPOTENCY_CONFLICT'});}); test('新数字人和同名新员工先登记,再经 Skill 保存正确归属并公开脱敏',async t=>{ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-registry-server-'));t.after(()=>{assert.equal(path.dirname(root),fs.realpathSync(os.tmpdir()));assert.ok(path.basename(root).startsWith('ksf-registry-server-'));fs.rmSync(root,{recursive:true});}); const options={profileDir:path.join(root,'agents'),participantDir:path.join(root,'participants')}; const agents=['研发助手甲','研发助手乙'].map(name=>{const r=registerAgentProfile({name,role:'workshop',identityBasis:'operator_registration',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',accountName:'private-'+name}},options);return readAgentProfileByKey(r.profile.key,options.profileDir);}); const people=[false,true].map(confirmNew=>{const r=registerParticipantProfile({name:'同名新员工',identityBasis:'participant_registration',account:{platform:'wecom'},...(confirmNew?{confirmNew:true}:{})},options);return readParticipantProfile(r.profile.key,options.participantDir);}); const {call,store}=setup(),sessions=[]; for(let i=0;i<2;i++){ const opts={requestId:'registered-start-'+i,call,agentProfile:agents[i],participantProfile:people[i]},input={workshopKey:'new-registry',participant:{profileKey:people[i].key},conversation:{key:'same-group',channel:'wecom'},dataMode:'sample'}; const ctx=await runWorkshop('workshop-start',input,opts);sessions.push(ctx.sessionId); assert.equal((await runWorkshop('workshop-start',input,opts)).sessionId,ctx.sessionId); const saved=await runWorkshop('workshop-save',task(ctx.sessionId,{requestId:undefined,taskKey:'new-s1-'+i,stageCode:'S1',artifactType:'category_report',opportunity:undefined}),{...opts,requestId:'registered-task-'+i}); const row=await store.get('KfArtifact',saved.artifactId),sessionRow=await store.get('KfSession',ctx.sessionId); assert.equal(row.agentName,agents[i].name);assert.equal(row.agentId,agents[i].key);assert.equal(row.uploadedByKey,sessionRow.meta.attribution.participantKey);assert.equal(sessionRow.meta.attribution.identityBasis,'participant_registration');assert.equal(row.dataMode,'sample'); } assert.notEqual(sessions[0],sessions[1]);const rows=await store.list('KfSession');assert.notEqual(rows[0].meta.attribution.participantKey,rows[1].meta.attribution.participantKey); const pub=await call('ksfGetPublicBoard',{},null);assert.equal(pub.session.length,2);assert.ok(!JSON.stringify(pub).includes('private-'));assert.equal((await store.list('_User')).length,0); }); test('课堂报告/机会保留 sample,保存有归属且原子重放不重复',async()=>{const{call,store}=setup();const ctx=await call('ksfStartWorkshopSession',start);const a=await call('ksfSaveWorkshopTask',task(ctx.sessionId)),b=await call('ksfSaveWorkshopTask',task(ctx.sessionId));assert.deepEqual(a,b);assert.equal((await store.list('KfArtifact')).length,1);const artifact=await store.get('KfArtifact',a.artifactId);assert.equal(artifact.agentName,'王刚001');assert.equal(artifact.ownerName,'演练人员');assert.equal(artifact.dashboardState,'workshop');assert.equal(artifact.dataMode,'sample');assert.equal(artifact.contentConfirmedAt,undefined);assert.equal(artifact.sources.length,0);assert.match(artifact.title,/样例/);assert.equal(a.formalReportPublished,false);await assert.rejects(()=>call('ksfSaveWorkshopTask',task(ctx.sessionId,{requestId:'different',title:'changed'})),{code:'IDEMPOTENCY_CONFLICT'});assert.equal((await store.list('KfSource')).length,1,'冲突回滚来源登记');}); test('真实项目、阶段门槛、方案版本与完整事件可回读',async()=>{const{call,store,ctx,proj}=await seeded();const base={sessionId:ctx.sessionId,projectId:proj.projectId};assert.ok((await store.get('KfProject',proj.projectId)).opportunityRef);await assert.rejects(()=>call('ksfTransitionProject',{...base,requestId:'skip',toStage:'S5',state:'in_progress',note:'跳过'}),{code:'VALIDATION_FAILED'});await assert.rejects(()=>call('ksfTransitionProject',{...base,requestId:'missing',toStage:'S4',state:'in_progress',note:'尚无成果'}),{code:'STAGE_REQUIREMENTS_NOT_MET'}); await call('ksfSaveWorkshopTask',task(ctx.sessionId,{...base,requestId:'project-s3',taskKey:'project-s3',opportunity:undefined})); await call('ksfTransitionProject',{...base,requestId:'to-s4',toStage:'S4',state:'in_progress',note:'进入验证'}); await call('ksfSaveWorkshopTask',task(ctx.sessionId,{...base,requestId:'project-s4',taskKey:'project-s4',stageCode:'S4',artifactType:'assessment_report',opportunity:undefined})); await call('ksfTransitionProject',{...base,requestId:'to-s5',toStage:'S5',state:'in_progress',note:'进入方案'}); const concept=await call('ksfSaveWorkshopConcept',{...base,requestId:'concept',version:1,concept:{name:'演练方案',targetAudience:'待验证',usageScenario:'待验证',specification:{packaging:'待验证'},valueProposition:'待验证',productionSteps:[],pendingQuestions:['无企业工艺资料']},formulaItems:[]});assert.equal(concept.version,1); await call('ksfSaveWorkshopTask',task(ctx.sessionId,{...base,requestId:'project-s5',taskKey:'project-s5',stageCode:'S5',artifactType:'concept',opportunity:undefined})); await call('ksfTransitionProject',{...base,requestId:'complete',toStage:'S5',state:'completed',note:'课堂流程完成,业务条件仍待验证'}); const result=await call('ksfGetWorkshopProject',base);assert.equal(result.project.progressState,'completed');assert.equal(result.artifacts.length,3);assert.equal(result.concept.objectId,concept.conceptId);assert.equal(result.events.length,8);assert.deepEqual(result.events.map(x=>x.sequence),[1,2,3,4,5,6,7,8]); }); test('其他服务/学员/小组不能写入别人的项目',async()=>{const{call,ctx,proj}=await seeded();await assert.rejects(()=>call('ksfSaveWorkshopTask',task(ctx.sessionId),other),{code:'FORBIDDEN'});const outsider=await call('ksfStartWorkshopSession',{...start,requestId:'other-student',groupId:'G2',participant:{...start.participant,key:'other-user'}});await assert.rejects(()=>call('ksfGetWorkshopProject',{sessionId:outsider.sessionId,projectId:proj.projectId}),{code:'FORBIDDEN'});}); test('公开分页无需登录,含课堂样例但不泄露私密会话/普通原文/未发布正式稿',async()=>{const{call,store}=await seeded();await store.create('KfArtifact',{title:'private',schemaVersion:'ksf.v0.7',dashboardState:'unpublished'});await store.create('KfSource',{title:'private source',...{schemaVersion:'ksf.v0.7'},rawText:'private'});const raw={artifact:[],session:[],source:[]};let cursor;do{const r=await call('ksfGetPublicBoard',{limit:1,...(cursor?{cursor}:{})},null);for(const k of Object.keys(raw))raw[k].push(...r[k]);cursor=r.nextCursor;}while(cursor);assert.equal(raw.artifact.length,1);assert.match(raw.artifact[0].title,/样例/);assert.equal(raw.source.length,1);assert.equal(raw.source[0].rawText,undefined);assert.equal(raw.session[0].meta.agentName,'王刚001');assert.ok(!JSON.stringify(raw).includes('conversation-1'));assert.ok(!JSON.stringify(raw).includes('service-a'));await assert.rejects(()=>call('ksfSaveWorkshopTask',{},null),{code:'AUTH_REQUIRED'});}); test('完整验收任务从已登记aibot员工走S1-S5、检索选卡、资料依赖、卡住和新品方案,公开回读一致',async t=>{ const root=fs.mkdtempSync(path.join(os.tmpdir(),'ksf-full-chain-'));t.after(()=>fs.rmSync(root,{recursive:true})); const options={profileDir:path.join(root,'agents'),participantDir:path.join(root,'participants')}; const registered=registerAgentProfile({name:'王刚001',role:'workshop',identityBasis:'runtime_config',runtimePlatform:'hermes',channel:'wecom',account:{platform:'wecom',accountId:'private-bot'}},options).profile; const agentProfile=readAgentProfileByKey(registered.key,options.profileDir); const employee=registerChannelParticipant({version:'ksf.wecom-aibot-context.v1',botId:'private-bot',senderId:'private-sender',chatId:null,chatType:'single',messageId:'message-1'},{...options,agentProfile}); const participantProfile=readParticipantProfile(employee.profile.key,options.participantDir),fixture=setup(),store=fixture.store,history=[],checkpoints=[]; const call=(name,input)=>fixture.call(name,input,{...actor,scopes:[...actor.scopes,'source:write']}); const context={version:'ksf.full-workshop-acceptance.v1',workshopKey:'KSF-企微实测-20261101-K',agentProfileKey:agentProfile.key,participantProfileKey:participantProfile.key,conversationKey:'private-current-chat'}; assert.throws(()=>validateContext({...context,token:'not-allowed'}),{code:'INVALID_ACCEPTANCE_CONTEXT'}); const result=await runFullChain({context,invoke:async(command,input,requestId)=>{history.push({command,input,requestId});return runWorkshop(command,input,{requestId,agentProfile,participantProfile,call});},publicRead:()=>call('ksfGetPublicBoard',{},null),checkpoint:x=>checkpoints.push(x)}); assert.equal(result.ok,true);assert.equal(result.progressState,'completed');assert.equal(result.artifacts.length,6);assert.equal(result.projectArtifactCount,3);assert.equal(result.formalReportPublished,false);assert.equal(result.browserVerified,false); assert.equal((await store.list('KfCategory')).length,1);assert.equal((await store.list('KfProduct')).length,1);assert.equal((await store.list('KfMaterial')).length,1);assert.equal((await store.list('KfProcess')).length,1); assert.equal((await store.list('_User')).length,0); assert.ok(history.findIndex(x=>x.command==='workshop-search'&&x.input.className==='KfOpportunity')x.command==='project-create')); const imported=history.find(x=>x.command==='workshop-import').input; const repeat=structuredClone(imported);for(const item of repeat.records)item.externalKey+=':another-batch'; const reused=await runWorkshop('workshop-import',repeat,{requestId:'acceptance-dedup-batch',agentProfile,call}); assert.equal(reused.sourceOperation,'unchanged');assert.equal(reused.counts.created,0);assert.equal(reused.counts.unchanged,4); for(const name of ['KfCategory','KfProduct','KfMaterial','KfProcess'])assert.equal((await store.list(name)).length,1); assert.ok(checkpoints.some(x=>x.status==='public-blocked-verified')); for(const a of await store.list('KfArtifact')){assert.equal(a.dataMode,'sample');assert.equal(a.meta.attribution.agentName,'王刚001');assert.equal(a.meta.attribution.participantAccount.accountId,'private-sender');} const pub=await call('ksfGetPublicBoard',{},null);assert.ok(!JSON.stringify(pub).includes('private-')); });