|
|
@@ -33,6 +33,7 @@ let requests: Array<{ method: string; params: Record<string, any> }>,
|
|
|
let nodes: WorkspaceNodes
|
|
|
let configuredCwds: Map<string, string>
|
|
|
let rejectPrompt: string | undefined
|
|
|
+let rejectedModelConfig: Record<string, unknown> | undefined
|
|
|
let rejectedInterrupts: number
|
|
|
let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
|
|
|
let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
|
|
|
@@ -47,6 +48,7 @@ beforeEach(async () => {
|
|
|
recoveryHistory = []
|
|
|
storedByRuntime = new Map(); recoveryByStored = new Map()
|
|
|
rejectPrompt = undefined
|
|
|
+ rejectedModelConfig = undefined
|
|
|
rejectedInterrupts = 0
|
|
|
server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
|
|
|
await new Promise<void>((resolve) => server.once('listening', resolve))
|
|
|
@@ -93,6 +95,7 @@ beforeEach(async () => {
|
|
|
respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile } })
|
|
|
}
|
|
|
else if (frame.method === 'session.cwd.set') respond({ cwd: frame.params.cwd })
|
|
|
+ else if (frame.method === 'config.set') respond(rejectedModelConfig ?? { value: frame.params.key === 'model' ? frame.params.value.split(' --provider ')[0] : frame.params.value, scope:'session' })
|
|
|
else if (frame.method === 'prompt.submit') {
|
|
|
if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
|
|
|
respond({ status: 'streaming' })
|
|
|
@@ -528,8 +531,10 @@ describe('Web-owned workspace', () => {
|
|
|
expect(store.get<{ percent: number }>(owner, 'context', c.id)?.percent).toBe(40)
|
|
|
})
|
|
|
it('resumes a persisted uncertain run by inspecting history, without resending the prompt', async () => {
|
|
|
+ modelBridge()
|
|
|
const a = agent('恢复测试'),
|
|
|
c = direct(a.id)
|
|
|
+ store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
|
|
|
reply = (socket, p) => {
|
|
|
recoveryHistory = [
|
|
|
{ role: 'user', content: p.text },
|
|
|
@@ -542,10 +547,14 @@ describe('Web-owned workspace', () => {
|
|
|
expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('uncertain'),
|
|
|
)
|
|
|
runtime.close()
|
|
|
+ const configured = requests.filter(r => r.method === 'config.set').length
|
|
|
+ expect(configured).toBe(3)
|
|
|
+ store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-b', reasoningEffort: 'none', fastMode: 'cold' } })
|
|
|
runtime = new WorkspaceRuntime(store, nodes, uploads)
|
|
|
await runtime.reconcile(owner, run.id)
|
|
|
await finished(run.id)
|
|
|
expect(requests.filter((r) => r.method === 'prompt.submit')).toHaveLength(1)
|
|
|
+ expect(requests.filter(r => r.method === 'config.set')).toHaveLength(configured)
|
|
|
expect(store.messages(owner, c.id).map((m) => m.content)).toEqual(['修改一次', '已经执行完成'])
|
|
|
})
|
|
|
it('routes approvals and clarifications to the owning run and ignores optional push failures', async () => {
|
|
|
@@ -1725,3 +1734,70 @@ it('offers a direct host-to-host copy through the real Bot tool bridge without p
|
|
|
expect(publish).not.toHaveBeenCalled();expect(store.list(owner,'file')).toEqual([])
|
|
|
}finally{timers.forEach(clearInterval);desktop.close()}
|
|
|
})
|
|
|
+
|
|
|
+function modelBridge() {
|
|
|
+ const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
|
|
|
+ target.session.request = async (path, options) => {
|
|
|
+ if (path.endsWith('/model-settings/resolve')) {
|
|
|
+ const settings = (options?.body as any)?.settings ?? {}
|
|
|
+ return { status:200, headers:new Headers(), body:Buffer.from(JSON.stringify({version:1,confirmationMessage:null,effective:{provider:settings.provider??'base',model:settings.model??'base-model',reasoningEffort:settings.reasoningEffort??'medium',fastMode:settings.fastMode??'normal'}})) }
|
|
|
+ }
|
|
|
+ return original(path, options)
|
|
|
+ }
|
|
|
+}
|
|
|
+it.each([{value:'wrong'}, {confirm_required:true,confirm_message:'切换长上下文将增加费用'}])('stops before submission on a failed native model acknowledgement and keeps its confirmation in the profile: %j',async rejection=>{
|
|
|
+ modelBridge(); rejectedModelConfig=rejection
|
|
|
+ const a=agent('配置失败'),c=direct(a.id)
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-a',reasoningEffort:'high',fastMode:'fast'}})
|
|
|
+ const record=vi.fn(); runtime.inspector={record} as any
|
|
|
+ const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能用旧模型执行'})
|
|
|
+ await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
|
|
|
+ expect(requests.some(r=>r.method==='prompt.submit')).toBe(false)
|
|
|
+ expect(record.mock.calls.some(([, , e])=>e.method==='bot.model-settings'&&e.data.applied===false&&e.data.revision===2)).toBe(true)
|
|
|
+ if('confirm_required' in rejection) expect(store.require<any>(owner,'agent',a.id).modelSettingsPendingConfirmation).toEqual({target:'["openai","model-a"]',message:rejection.confirm_message})
|
|
|
+})
|
|
|
+it('updates the same Bot across an existing direct chat and two groups without changing its Profile peers', async () => {
|
|
|
+ modelBridge()
|
|
|
+ const a=agent('模型甲'), b=agent('模型乙'), c=direct(a.id)
|
|
|
+ const groups=[1,2].map(n=>store.createGroup(owner,{name:`模型群${n}`,memberIds:[a.id,b.id],administratorId:a.id}))
|
|
|
+ const settings={provider:'openai',model:'model-a',reasoningEffort:'high',fastMode:'fast'}
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:settings})
|
|
|
+ await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'建立已有会话'}).id)
|
|
|
+ const before=requests.length
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:{...settings,model:'model-b',reasoningEffort:'none',fastMode:'cold'}})
|
|
|
+ for(const chat of [c,...groups]) await finished(runtime.send(owner,chat.id,{requestId:randomUUID(),content:'@模型甲 继续'}).id)
|
|
|
+ const updates=requests.slice(before).filter(r=>r.method==='config.set')
|
|
|
+ expect(updates.map(r=>[r.params.key,r.params.value])).toEqual(Array.from({length:3},()=>[
|
|
|
+ ['model','model-b --provider openai --session'],['reasoning','none'],['fast','cold'],
|
|
|
+ ]).flat())
|
|
|
+ expect(requests.slice(before).some(r=>r.method==='session.resume')).toBe(true)
|
|
|
+ expect(store.require<any>(owner,'agent',b.id).modelSettings).toBeUndefined()
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:null})
|
|
|
+ const reset=requests.length
|
|
|
+ await finished(runtime.send(owner,c.id,{requestId:randomUUID(),content:'恢复继承'}).id)
|
|
|
+ expect(requests.slice(reset).filter(r=>r.method==='config.set').map(r=>r.params.value)).toEqual(['base-model --provider base --session','medium','normal'])
|
|
|
+})
|
|
|
+it('keeps a running reply on its snapshot and applies new settings to the queued reply', async () => {
|
|
|
+ modelBridge()
|
|
|
+ const a=agent('切换成员'), c=direct(a.id)
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-a',reasoningEffort:'low',fastMode:'normal'}})
|
|
|
+ let release:()=>void=()=>{}
|
|
|
+ reply=(socket,p)=>{release=()=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))}
|
|
|
+ const first=runtime.send(owner,c.id,{requestId:randomUUID(),content:'第一轮'})
|
|
|
+ await vi.waitFor(()=>expect(requests.some(r=>r.method==='prompt.submit')).toBe(true))
|
|
|
+ const before=requests.length
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'model-b',reasoningEffort:'ultra',fastMode:'auto'}})
|
|
|
+ const queued=runtime.send(owner,c.id,{requestId:randomUUID(),content:'排队的新轮'})
|
|
|
+ expect(requests.length).toBe(before)
|
|
|
+ reply=(socket,p)=>socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:p.session_id,payload:{text:'完成',status:'complete'}}}))
|
|
|
+ release(); await finished(first.id); await finished(queued.id)
|
|
|
+ expect(requests.slice(before).filter(r=>r.method==='config.set').map(r=>r.params.value)).toEqual(['model-b --provider openai --session','ultra','auto'])
|
|
|
+ expect(requests.filter(r=>r.method==='session.interrupt')).toHaveLength(0)
|
|
|
+})
|
|
|
+it('does not send a prompt when the model bridge cannot resolve the saved setting', async () => {
|
|
|
+ const a=agent('失败成员'),c=direct(a.id)
|
|
|
+ store.updateAgent(owner,a.id,{modelSettings:{provider:'openai',model:'gone',reasoningEffort:'high',fastMode:'fast'}})
|
|
|
+ const run=runtime.send(owner,c.id,{requestId:randomUUID(),content:'不能回退'})
|
|
|
+ await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
|
|
|
+ expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
|
|
|
+})
|