Fix conversation failure handling and confirmed delivery memories
This commit is contained in:
+1
-1
@@ -61,7 +61,7 @@ function CreateBot({state,act,busy,close}:any){const [name,setName]=useState('')
|
|||||||
function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState<Recipe>(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return <form onSubmit={async e=>{e.preventDefault();try{await act({type:'saveRecipe',recipe:{...r,tags:tags.split(',').map(t=>t.trim()).filter(Boolean)}});toast.success('Recipe saved');close();}catch{}}}><div className="form-grid"><label className="field full">Recipe name<Input required minLength={2} maxLength={80} value={r.name} onChange={e=>setR({...r,name:e.target.value})}/></label><label className="field full">Personality description<textarea maxLength={500} value={r.description} onChange={e=>setR({...r,description:e.target.value})}/></label><label className="field full">Tags, separated by commas<Input value={tags} onChange={e=>setTags(e.target.value)}/></label></div><div className="section-grid" style={{gap:25,marginTop:20}}><div>{(['sociability','aggression','curiosity','economy'] as const).map(key=><div className="range-row" key={key}><div className="range-label"><span style={{textTransform:'capitalize'}}>{key}</span><span>{r[key]}</span></div><Slider aria-label={key} min={0} max={100} step={1} value={[r[key]]} onValueChange={v=>setR({...r,[key]:v[0]})}/></div>)}</div><div><h3 style={{margin:'15px 0'}}>Preferred activities</h3><div className="choices">{ACTIVITIES.map(a=><label className="choice-row" key={a}><Checkbox checked={r.activities.includes(a)} onCheckedChange={v=>setR({...r,activities:v?[...r.activities,a]:r.activities.filter(x=>x!==a)})}/>{a}</label>)}</div></div></div><div className="form-footer">{recipe&&<Button type="button" variant="outline" disabled={busy} onClick={async()=>{try{const {id,version,...copy}=r;void id;void version;await act({type:'saveRecipe',recipe:{...copy,name:`${copy.name} copy`.slice(0,80)}});close();}catch{}}}><Copy size={15}/>Duplicate</Button>}<Button disabled={busy||!r.activities.length}>Save recipe</Button></div></form>}
|
function RecipeEditor({recipe,act,busy,close}:any){const [r,setR]=useState<Recipe>(recipe??{name:'',description:'',tags:['Curious'],sociability:50,aggression:20,curiosity:70,economy:30,activities:['explore','quest']});const [tags,setTags]=useState(r.tags.join(', '));return <form onSubmit={async e=>{e.preventDefault();try{await act({type:'saveRecipe',recipe:{...r,tags:tags.split(',').map(t=>t.trim()).filter(Boolean)}});toast.success('Recipe saved');close();}catch{}}}><div className="form-grid"><label className="field full">Recipe name<Input required minLength={2} maxLength={80} value={r.name} onChange={e=>setR({...r,name:e.target.value})}/></label><label className="field full">Personality description<textarea maxLength={500} value={r.description} onChange={e=>setR({...r,description:e.target.value})}/></label><label className="field full">Tags, separated by commas<Input value={tags} onChange={e=>setTags(e.target.value)}/></label></div><div className="section-grid" style={{gap:25,marginTop:20}}><div>{(['sociability','aggression','curiosity','economy'] as const).map(key=><div className="range-row" key={key}><div className="range-label"><span style={{textTransform:'capitalize'}}>{key}</span><span>{r[key]}</span></div><Slider aria-label={key} min={0} max={100} step={1} value={[r[key]]} onValueChange={v=>setR({...r,[key]:v[0]})}/></div>)}</div><div><h3 style={{margin:'15px 0'}}>Preferred activities</h3><div className="choices">{ACTIVITIES.map(a=><label className="choice-row" key={a}><Checkbox checked={r.activities.includes(a)} onCheckedChange={v=>setR({...r,activities:v?[...r.activities,a]:r.activities.filter(x=>x!==a)})}/>{a}</label>)}</div></div></div><div className="form-footer">{recipe&&<Button type="button" variant="outline" disabled={busy} onClick={async()=>{try{const {id,version,...copy}=r;void id;void version;await act({type:'saveRecipe',recipe:{...copy,name:`${copy.name} copy`.slice(0,80)}});close();}catch{}}}><Copy size={15}/>Duplicate</Button>}<Button disabled={busy||!r.activities.length}>Save recipe</Button></div></form>}
|
||||||
function BotProfile({bot:b,state,act,busy,perform,confirm}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <><div className="detail-header"><Avatar bot={b}/><div><h2>{b.name}</h2><p className="subtitle">Level {b.level} {b.race} {b.role}</p></div><span className={`tag ${b.status}`} style={{marginLeft:'auto'}}>{b.status}</span></div><div className="actions">{actions.map(a=><Button key={a} size="sm" variant={a==='spawn'?'default':'outline'} disabled={busy} onClick={()=>a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?<Pause size={14}/>:a==='spawn'||a==='resume'?<Play size={14}/>:null}{a.charAt(0).toUpperCase()+a.slice(1)}</Button>)}<Button size="sm" disabled={busy||b.status!=='online'} onClick={()=>perform({type:'think',botId:b.id})}><Brain size={15}/>{busy?'Working…':'Run decision'}</Button></div><Tabs defaultValue="identity" className="mt-6"><TabsList className="w-full"><TabsTrigger value="identity">Identity</TabsTrigger><TabsTrigger value="memory">Memory</TabsTrigger><TabsTrigger value="bonds">Relationships</TabsTrigger></TabsList><TabsContent value="identity"><div className="detail-block"><h3>Current goal</h3><p>{b.goal}</p><p style={{marginTop:12,display:'flex',gap:7,alignItems:'center'}}><MapPin size={15}/>{b.zone}</p></div><div className="detail-block"><h3>{b.personality.name} <small>v{b.recipeVersion}</small></h3><p>{b.personality.description}</p><div className="actions" style={{marginTop:12}}>{b.personality.tags.map((t:string)=><span className="tag" key={t}>{t}</span>)}</div><div className="field" style={{marginTop:20}}><Choice label="Apply recipe" value={recipeId} onChange={setRecipeId} items={state.recipes.map((r:Recipe)=>({value:r.id,label:`${r.name} · v${r.version}`}))}/><Button variant="outline" disabled={busy} onClick={()=>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version</Button></div></div><div className="detail-block"><h3>Latest decision</h3><p>{b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}</p><small>{b.decisions} decisions acknowledged</small></div></TabsContent><TabsContent value="memory"><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}><label className="field">Add a memory<textarea required value={memory} maxLength={1000} onChange={e=>setMemory(e.target.value)} placeholder="What should this character remember?"/></label><div className="range-row"><div className="range-label">Importance <span>{importance}/10</span></div><Slider aria-label="Memory importance" min={1} max={10} value={[importance]} onValueChange={v=>setImportance(v[0])}/></div><Button disabled={busy}>Save memory</Button></form>{[...b.memories].reverse().map((m:any)=><div className="memory" key={m.id}><p className="list-muted">{m.text}</p><small>{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()}</small></div>)}</TabsContent><TabsContent value="bonds"><p className="fineprint">Bonds are directional. One character’s trust does not imply the other’s.</p><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}><div className="field"><label>Other character</label><Choice label="Other character" value={target} onChange={setTarget} items={state.bots.filter((x:Bot)=>x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/></div><div className="range-row"><div className="range-label">Affinity <span>{affinity>0?'+':''}{affinity}</span></div><Slider aria-label="Affinity" min={-100} max={100} value={[affinity]} onValueChange={v=>setAffinity(v[0])}/></div><label className="field">Why this bond exists<textarea required maxLength={300} value={note} onChange={e=>setNote(e.target.value)}/></label><Button className="mt-4" disabled={busy||!target}>Save relationship</Button></form>{b.relationships.map((r:any)=><div className="memory" key={r.targetId}><h3>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} <span className="tag">{r.affinity>0?'+':''}{r.affinity}</span></h3><p className="list-muted">{r.note}</p></div>)}</TabsContent></Tabs></>}
|
function BotProfile({bot:b,state,act,busy,perform,confirm}:any){const [memory,setMemory]=useState(''),[importance,setImportance]=useState(7),[target,setTarget]=useState(state.bots.find((x:Bot)=>x.id!==b.id)?.id??''),[affinity,setAffinity]=useState(40),[note,setNote]=useState(''),[recipeId,setRecipeId]=useState(b.recipeId);const actions=b.status==='online'?['pause','despawn']:b.status==='paused'?['resume','despawn']:b.status==='offline'?['spawn','retire']:[];return <><div className="detail-header"><Avatar bot={b}/><div><h2>{b.name}</h2><p className="subtitle">Level {b.level} {b.race} {b.role}</p></div><span className={`tag ${b.status}`} style={{marginLeft:'auto'}}>{b.status}</span></div><div className="actions">{actions.map(a=><Button key={a} size="sm" variant={a==='spawn'?'default':'outline'} disabled={busy} onClick={()=>a==='retire'?confirm({title:`Retire ${b.name}?`,description:'The identity and memories remain in your realm, but this bot cannot spawn again in this prototype.',action:{type:'lifecycle',botId:b.id,action:a}}):perform({type:'lifecycle',botId:b.id,action:a})}>{a==='pause'?<Pause size={14}/>:a==='spawn'||a==='resume'?<Play size={14}/>:null}{a.charAt(0).toUpperCase()+a.slice(1)}</Button>)}<Button size="sm" disabled={busy||b.status!=='online'} onClick={()=>perform({type:'think',botId:b.id})}><Brain size={15}/>{busy?'Working…':'Run decision'}</Button></div><Tabs defaultValue="identity" className="mt-6"><TabsList className="w-full"><TabsTrigger value="identity">Identity</TabsTrigger><TabsTrigger value="memory">Memory</TabsTrigger><TabsTrigger value="bonds">Relationships</TabsTrigger></TabsList><TabsContent value="identity"><div className="detail-block"><h3>Current goal</h3><p>{b.goal}</p><p style={{marginTop:12,display:'flex',gap:7,alignItems:'center'}}><MapPin size={15}/>{b.zone}</p></div><div className="detail-block"><h3>{b.personality.name} <small>v{b.recipeVersion}</small></h3><p>{b.personality.description}</p><div className="actions" style={{marginTop:12}}>{b.personality.tags.map((t:string)=><span className="tag" key={t}>{t}</span>)}</div><div className="field" style={{marginTop:20}}><Choice label="Apply recipe" value={recipeId} onChange={setRecipeId} items={state.recipes.map((r:Recipe)=>({value:r.id,label:`${r.name} · v${r.version}`}))}/><Button variant="outline" disabled={busy} onClick={()=>perform({type:'applyRecipe',botId:b.id,recipeId})}>Apply current recipe version</Button></div></div><div className="detail-block"><h3>Latest decision</h3><p>{b.lastDecision?`${b.lastDecision.action}: ${b.lastDecision.reason}`:'No decisions yet. Run a decision while this bot is online.'}</p><small>{b.decisions} decisions acknowledged</small></div></TabsContent><TabsContent value="memory"><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'memory',botId:b.id,text:memory,importance});setMemory('');}catch{}}}><label className="field">Add a memory<textarea required value={memory} maxLength={1000} onChange={e=>setMemory(e.target.value)} placeholder="What should this character remember?"/></label><div className="range-row"><div className="range-label">Importance <span>{importance}/10</span></div><Slider aria-label="Memory importance" min={1} max={10} value={[importance]} onValueChange={v=>setImportance(v[0])}/></div><Button disabled={busy}>Save memory</Button></form>{[...b.memories].reverse().map((m:any)=><div className="memory" key={m.id}><p className="list-muted">{m.text}</p><small>{m.source} · Importance {m.importance}/10 · {new Date(m.at).toLocaleDateString()}</small></div>)}</TabsContent><TabsContent value="bonds"><p className="fineprint">Bonds are directional. One character’s trust does not imply the other’s.</p><form className="detail-block" onSubmit={async e=>{e.preventDefault();try{await act({type:'relationship',botId:b.id,targetId:target,affinity,note});setNote('');}catch{}}}><div className="field"><label>Other character</label><Choice label="Other character" value={target} onChange={setTarget} items={state.bots.filter((x:Bot)=>x.id!==b.id).map((x:Bot)=>({value:x.id,label:x.name}))}/></div><div className="range-row"><div className="range-label">Affinity <span>{affinity>0?'+':''}{affinity}</span></div><Slider aria-label="Affinity" min={-100} max={100} value={[affinity]} onValueChange={v=>setAffinity(v[0])}/></div><label className="field">Why this bond exists<textarea required maxLength={300} value={note} onChange={e=>setNote(e.target.value)}/></label><Button className="mt-4" disabled={busy||!target}>Save relationship</Button></form>{b.relationships.map((r:any)=><div className="memory" key={r.targetId}><h3>{state.bots.find((x:Bot)=>x.id===r.targetId)?.name} <span className="tag">{r.affinity>0?'+':''}{r.affinity}</span></h3><p className="list-muted">{r.note}</p></div>)}</TabsContent></Tabs></>}
|
||||||
function WorldMonitor({state,monitor,capabilities,openBot}:any){
|
function WorldMonitor({state,monitor,capabilities,openBot}:any){
|
||||||
const living=state.bots.filter((b:Bot)=>b.status!=='dead'&&b.status!=='retired');
|
const living:Bot[]=state.bots.filter((b:Bot)=>b.status!=='dead'&&b.status!=='retired');
|
||||||
const active=living.filter((b:Bot)=>b.status==='online');
|
const active=living.filter((b:Bot)=>b.status==='online');
|
||||||
const managed=living.filter((b:Bot)=>b.autonomy?.managed===true);
|
const managed=living.filter((b:Bot)=>b.autonomy?.managed===true);
|
||||||
const foreground=living.filter((b:Bot)=>b.simulationBand==='foreground');
|
const foreground=living.filter((b:Bot)=>b.simulationBand==='foreground');
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type ConversationRequest={
|
|||||||
receiver:string;
|
receiver:string;
|
||||||
selectedPlayer:string;
|
selectedPlayer:string;
|
||||||
nearbyBots:string[];
|
nearbyBots:string[];
|
||||||
status:'pending'|'processing'|'completed'|'failed';
|
status:'pending'|'processing'|'awaiting_delivery'|'completed'|'failed';
|
||||||
attempts:number;
|
attempts:number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,3 +124,20 @@ export async function cleanupConversations(db:D1Database){
|
|||||||
WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`)
|
WHERE status IN ('completed','failed') AND completed_at IS NOT NULL AND completed_at<?`)
|
||||||
.bind(before).run();
|
.bind(before).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function awaitDelivery(db:D1Database,request:ConversationRequest,botId:string,reply:string,commandId:string){
|
||||||
|
await db.prepare("UPDATE conversation_requests SET status='awaiting_delivery',result=? WHERE id=?")
|
||||||
|
.bind(JSON.stringify({request,botId,reply,commandId}),request.id).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pendingDeliveries(db:D1Database){
|
||||||
|
await ensure(db);
|
||||||
|
const rows=await db.prepare("SELECT result FROM conversation_requests WHERE status='awaiting_delivery' ORDER BY created_at LIMIT 50").all<{result:string}>();
|
||||||
|
const deliveries=[];
|
||||||
|
for(const row of rows.results){
|
||||||
|
const delivery=JSON.parse(row.result) as {request:ConversationRequest;botId:string;reply:string;commandId:string};
|
||||||
|
const command=await db.prepare('SELECT status,error FROM ale_commands WHERE id=?').bind(delivery.commandId).first<{status:string;error:string|null}>();
|
||||||
|
deliveries.push({...delivery,status:command?.status??'pending',error:command?.error});
|
||||||
|
}
|
||||||
|
return deliveries;
|
||||||
|
}
|
||||||
|
|||||||
+14
-17
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { queueAleCommand } from './ale-store';
|
import { queueAleCommand } from './ale-store';
|
||||||
import { luaString } from './ale';
|
import { luaString } from './ale';
|
||||||
import { cleanupConversations,completeConversation,failConversation,leaseConversation,type ConversationRequest } from './conversation-store';
|
import { cleanupConversations,completeConversation,failConversation,leaseConversation,awaitDelivery,pendingDeliveries,type ConversationRequest } from './conversation-store';
|
||||||
import type { Bot,RealmEvent,RuntimeConfig,State,StateStore } from './types';
|
import type { Bot,RealmEvent,RuntimeConfig,State,StateStore } from './types';
|
||||||
|
|
||||||
const replySchema=z.object({reply:z.string().min(1).max(500)}).strict();
|
const replySchema=z.object({reply:z.string().min(1).max(500)}).strict();
|
||||||
@@ -54,14 +54,8 @@ function chooseTarget(state:State,request:ConversationRequest){
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackReply(bot:Bot,request:ConversationRequest){
|
|
||||||
if(/\b(hello|hi|hey|yo)\b/i.test(request.message))return `Hello, ${request.speakerName}.`;
|
|
||||||
if(request.message.includes('?'))return `I'm not sure, ${request.speakerName}. We can figure it out together.`;
|
|
||||||
return `I hear you, ${request.speakerName}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function modelReply(bot:Bot,request:ConversationRequest,state:State,config:RuntimeConfig){
|
async function modelReply(bot:Bot,request:ConversationRequest,state:State,config:RuntimeConfig){
|
||||||
if(!config.LLM_URL||state.settings.provider==='deterministic')return fallbackReply(bot,request);
|
if(!config.LLM_URL||state.settings.provider==='deterministic')throw new Error('Conversation model is not configured; no canned reply was sent');
|
||||||
|
|
||||||
const brains=state.settings.brains;
|
const brains=state.settings.brains;
|
||||||
const hero=bot.tier==='hero';
|
const hero=bot.tier==='hero';
|
||||||
@@ -133,8 +127,9 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
|
|||||||
const revision=state.revision;
|
const revision=state.revision;
|
||||||
const bot=state.bots.find(x=>x.id===botId);
|
const bot=state.bots.find(x=>x.id===botId);
|
||||||
if(!bot)return;
|
if(!bot)return;
|
||||||
|
if(bot.memories.some(memory=>memory.id===request.id))return;
|
||||||
bot.memories.push({
|
bot.memories.push({
|
||||||
id:crypto.randomUUID(),
|
id:request.id,
|
||||||
text:`${request.speakerName} said: "${request.message.slice(0,180)}" I replied: "${reply.slice(0,180)}"`,
|
text:`${request.speakerName} said: "${request.message.slice(0,180)}" I replied: "${reply.slice(0,180)}"`,
|
||||||
importance:request.channel==='whisper'?7:6,
|
importance:request.channel==='whisper'?7:6,
|
||||||
source:'observation',
|
source:'observation',
|
||||||
@@ -153,6 +148,14 @@ async function remember(store:StateStore,botId:string,request:ConversationReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){
|
export async function runConversationTick(store:StateStore,db:D1Database,config:RuntimeConfig){
|
||||||
|
for(const delivery of await pendingDeliveries(db)){
|
||||||
|
if(delivery.status==='completed'){
|
||||||
|
await remember(store,delivery.botId,delivery.request,delivery.reply);
|
||||||
|
await completeConversation(db,delivery.request.id,JSON.stringify({commandId:delivery.commandId,delivered:true}));
|
||||||
|
}else if(delivery.status==='failed'){
|
||||||
|
await failConversation(db,delivery.request.id,delivery.error||'Game rejected conversation delivery');
|
||||||
|
}
|
||||||
|
}
|
||||||
const request=await leaseConversation(db);
|
const request=await leaseConversation(db);
|
||||||
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
if(!request)return {ok:true,ran:false,reason:'No player conversation waiting'};
|
||||||
|
|
||||||
@@ -164,18 +167,12 @@ export async function runConversationTick(store:StateStore,db:D1Database,config:
|
|||||||
return {ok:true,ran:false,reason:'No addressed managed Playerbot matched',speaker:request.speakerName};
|
return {ok:true,ran:false,reason:'No addressed managed Playerbot matched',speaker:request.speakerName};
|
||||||
}
|
}
|
||||||
|
|
||||||
let reply:string;
|
let reply=await modelReply(bot,request,state,config);
|
||||||
try{
|
|
||||||
reply=await modelReply(bot,request,state,config);
|
|
||||||
}catch{
|
|
||||||
reply=fallbackReply(bot,request);
|
|
||||||
}
|
|
||||||
reply=cleanReply(reply);
|
reply=cleanReply(reply);
|
||||||
if(!reply)throw new Error('Conversation reply was empty');
|
if(!reply)throw new Error('Conversation reply was empty');
|
||||||
|
|
||||||
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel));
|
const command=await queueAleCommand(db,compileReply(bot.name,request.speakerName,reply,request.channel));
|
||||||
await remember(store,bot.id,request,reply);
|
await awaitDelivery(db,request,bot.id,reply,command.id);
|
||||||
await completeConversation(db,request.id,JSON.stringify({bot:bot.name,reply,commandId:command.id}));
|
|
||||||
await cleanupConversations(db);
|
await cleanupConversations(db);
|
||||||
|
|
||||||
return {ok:true,ran:true,speaker:request.speakerName,name:bot.name,channel:request.channel,reply,commandId:command.id};
|
return {ok:true,ran:true,speaker:request.speakerName,name:bot.name,channel:request.channel,reply,commandId:command.id};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {spawnSync} from 'node:child_process';
|
import {spawnSync} from 'node:child_process';
|
||||||
import {writeFileSync,mkdirSync} from 'node:fs';
|
import {writeFileSync,mkdirSync} from 'node:fs';
|
||||||
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
|
const result=spawnSync(process.execPath,['node_modules/typescript/bin/tsc','core/service.ts','core/seed.ts','core/ale.ts','core/conversation.ts','--types','@cloudflare/workers-types,node','--outDir','.test-build','--module','commonjs','--target','es2022','--moduleResolution','node','--esModuleInterop','--skipLibCheck','--strict'],{stdio:'inherit'});
|
||||||
if(result.status)process.exit(result.status);
|
if(result.status)process.exit(result.status);
|
||||||
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
|
mkdirSync('.test-build',{recursive:true});writeFileSync('.test-build/package.json','{"type":"commonjs"}');
|
||||||
process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs'],{stdio:'inherit'}).status??1);
|
process.exit(spawnSync(process.execPath,['--test','tests/core.test.cjs','tests/hardcore.test.cjs','tests/living-world.test.cjs','tests/ale.test.cjs','tests/brains.test.cjs','tests/conversation.test.cjs'],{stdio:'inherit'}).status??1);
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const {test}=require('node:test');
|
||||||
|
const assert=require('node:assert/strict');
|
||||||
|
const queue=require('../.test-build/conversation-store.js');
|
||||||
|
const ale=require('../.test-build/ale-store.js');
|
||||||
|
const {runConversationTick}=require('../.test-build/conversation.js');
|
||||||
|
|
||||||
|
test('conversation failure, queued delivery, confirmed memory and replay safety',async()=>{
|
||||||
|
const request={id:'request-1',speakerName:'Player',message:'Hi',channel:'whisper',receiver:'Resident',nearbyBots:[]};
|
||||||
|
const bot={id:'bot-1',name:'Resident',status:'online',autonomy:{managed:true},memories:[],personality:{},race:'Orc',role:'Warrior'};
|
||||||
|
const state={revision:0,sequence:0,events:[],bots:[bot],settings:{provider:'ollama',model:'test',brains:{enabled:false,residentMemoryLimit:5,residentMaxTokens:100}}};
|
||||||
|
const store={load:async()=>state,save:async()=>{}};
|
||||||
|
let pending=request,delivery=[],queued=0,failed='',completed=0;
|
||||||
|
queue.pendingDeliveries=async()=>delivery;
|
||||||
|
queue.leaseConversation=async()=>{const r=pending;pending=null;return r;};
|
||||||
|
queue.failConversation=async(_db,_id,error)=>{failed=error;};
|
||||||
|
queue.completeConversation=async()=>{completed++;};
|
||||||
|
queue.cleanupConversations=async()=>{};
|
||||||
|
queue.awaitDelivery=async(_db,r,botId,reply,commandId)=>{delivery=[{request:r,botId,reply,commandId,status:'pending'}];};
|
||||||
|
ale.queueAleCommand=async()=>{queued++;return {id:'command-1'};};
|
||||||
|
const originalFetch=global.fetch;
|
||||||
|
try{
|
||||||
|
await assert.rejects(runConversationTick(store,{},{}),/not configured/);
|
||||||
|
assert.match(failed,/no canned reply/);assert.equal(queued,0);assert.equal(bot.memories.length,0);
|
||||||
|
pending=request;global.fetch=async()=>({ok:false,status:503});
|
||||||
|
await assert.rejects(runConversationTick(store,{}, {LLM_URL:'http://model'}),/503/);
|
||||||
|
assert.equal(queued,0);
|
||||||
|
pending=request;global.fetch=async()=>({ok:true,json:async()=>({message:{content:'{"reply":"Hello, traveler."}'}})});
|
||||||
|
await runConversationTick(store,{}, {LLM_URL:'http://model'});
|
||||||
|
assert.equal(queued,1);assert.equal(bot.memories.length,0);assert.equal(completed,0);
|
||||||
|
await runConversationTick(store,{},{});
|
||||||
|
assert.equal(bot.memories.length,0);
|
||||||
|
delivery[0].status='completed';
|
||||||
|
await runConversationTick(store,{},{});
|
||||||
|
assert.equal(bot.memories.length,1);assert.equal(completed,1);
|
||||||
|
await runConversationTick(store,{},{});
|
||||||
|
assert.equal(bot.memories.length,1);
|
||||||
|
delivery=[{...delivery[0],request:{...request,id:'request-2'},status:'failed',error:'Bot offline'}];
|
||||||
|
await runConversationTick(store,{},{});
|
||||||
|
assert.equal(failed,'Bot offline');assert.equal(bot.memories.length,1);
|
||||||
|
}finally{global.fetch=originalFetch;}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user