fix(chat): support Hermes clarify batches
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { authenticate, mockChatSocket, mockHermesApi, TEST_ACCESS_KEY } from './fixtures'
|
||||
|
||||
test('answers sequential clarify questions with custom and multi-select responses', async ({ page }) => {
|
||||
await authenticate(page, TEST_ACCESS_KEY, 'research')
|
||||
await mockHermesApi(page)
|
||||
await mockChatSocket(page)
|
||||
await page.goto('/#/hermes/chat')
|
||||
await page.locator('textarea.input-textarea').fill('Ask me questions')
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click()
|
||||
const sessionId = await page.waitForFunction(() => (window as any).__PW_CHAT_SOCKET__?.emitted.find((item: any) => item.event === 'run')?.payload.session_id).then(handle => handle.jsonValue())
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('clarify.requested', { event: 'clarify.requested', session_id: sid, clarify_id: 'q1', question: 'Which day?', choices: ['Today', 'Yesterday'], multi_select: false })
|
||||
}, sessionId)
|
||||
await expect(page.getByText('Which day?')).toBeVisible()
|
||||
await page.getByPlaceholder('Type your answer...').fill('Another day')
|
||||
await page.getByRole('button', { name: 'Reply', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__PW_CHAT_SOCKET__.emitted.filter((e: any) => e.event === 'clarify.respond'))).toContainEqual({
|
||||
event: 'clarify.respond', payload: { session_id: sessionId, clarify_id: 'q1', response: 'Another day' },
|
||||
})
|
||||
await page.evaluate((sid) => {
|
||||
const socket = (window as any).__PW_CHAT_SOCKET__.latest
|
||||
socket.__trigger('clarify.requested', { event: 'clarify.requested', session_id: sid, clarify_id: 'q2', question: 'Which topics?', choices: ['News', 'Science'], multi_select: true })
|
||||
socket.__trigger('clarify.resolved', { event: 'clarify.resolved', session_id: sid, clarify_id: 'q1', resolved: true })
|
||||
}, sessionId)
|
||||
await expect(page.getByText('Which topics?')).toBeVisible()
|
||||
await page.getByText('News', { exact: true }).click()
|
||||
await page.getByText('Science', { exact: true }).click()
|
||||
await page.getByPlaceholder('Type your answer...').fill('Other')
|
||||
await page.getByRole('button', { name: 'Reply', exact: true }).click()
|
||||
await expect.poll(() => page.evaluate(() => (window as any).__PW_CHAT_SOCKET__.emitted.filter((e: any) => e.event === 'clarify.respond'))).toContainEqual({
|
||||
event: 'clarify.respond', payload: { session_id: sessionId, clarify_id: 'q2', response: '["News","Science","Other"]' },
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
describe('Hermes clarify bridge compatibility', () => {
|
||||
it('supports legacy callbacks, batches, multi-select, timeouts, and worker entrypoints', () => {
|
||||
const script = String.raw`
|
||||
import importlib.util, json, queue, sys, threading
|
||||
from pathlib import Path
|
||||
|
||||
root = Path('packages/server/src/services/hermes/agent-bridge/compat').resolve()
|
||||
sys.path.insert(0, str(root))
|
||||
from clarify_compat import make_clarify_callback
|
||||
|
||||
class Pool:
|
||||
def __init__(self, replies):
|
||||
self._lock = threading.RLock()
|
||||
self._clarify_requests = {}
|
||||
self.events = []
|
||||
self.replies = iter(replies)
|
||||
def _append_event(self, session, event):
|
||||
self.events.append((session, event))
|
||||
self._clarify_requests[event['clarify_id']].put_nowait(next(self.replies))
|
||||
|
||||
pool = Pool(['first', json.dumps(['A', 'Other']), ''])
|
||||
callback = make_clarify_callback(pool, 'session-1', lambda q, c: f'legacy:{q}:{c[0]}')
|
||||
assert callback('old question', ['old choice']) == 'legacy:old question:old choice'
|
||||
result = callback([
|
||||
{'qid': 'q0', 'question': 'First?', 'choices': ['first', 'second']},
|
||||
{'qid': 'q1', 'question': 'Pick several', 'choices': ['A', 'B'], 'multi_select': True},
|
||||
{'qid': 'q2', 'question': 'Optional question'},
|
||||
])
|
||||
assert result == {'answers': {'q0': 'first', 'q1': '["A", "Other"]', 'q2': None}, 'outcome': 'submitted'}
|
||||
assert [event['question'] for _, event in pool.events] == ['First?', 'Pick several', 'Optional question']
|
||||
assert [event['multi_select'] for _, event in pool.events] == [False, True, False]
|
||||
assert not pool._clarify_requests
|
||||
|
||||
class TimeoutQueue:
|
||||
def __init__(self, maxsize): pass
|
||||
def get(self, timeout): raise queue.Empty()
|
||||
pool = Pool([])
|
||||
pool._append_event = lambda session, event: None
|
||||
import clarify_compat
|
||||
clarify_compat.queue.Queue = TimeoutQueue
|
||||
assert make_clarify_callback(pool, 's', lambda *a: None)([{'qid': 'q0', 'question': 'timeout'}]) == {'answers': {}, 'outcome': 'timed_out'}
|
||||
assert not pool._clarify_requests
|
||||
|
||||
spec = importlib.util.spec_from_file_location('compat_entry', root / 'hermes_bridge.py')
|
||||
entry = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(entry)
|
||||
assert Path(entry.bridge._transport.__file__).resolve() == root / 'hermes_bridge.py'
|
||||
assert entry.bridge.AgentPool._clarify_callback is entry.clarify_callback
|
||||
`
|
||||
execFileSync('python3', ['-c', script], { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe' })
|
||||
})
|
||||
|
||||
it('starts a broker and profile worker through the compatibility entrypoint', () => {
|
||||
const script = String.raw`
|
||||
import json, os, socket, subprocess, sys, tempfile, time
|
||||
from pathlib import Path
|
||||
entry = Path('packages/server/src/services/hermes/agent-bridge/compat/hermes_bridge.py').resolve()
|
||||
with tempfile.TemporaryDirectory(prefix='clarify-bridge-test-') as temp:
|
||||
root = Path(temp)
|
||||
(root / 'run_agent.py').write_text('')
|
||||
endpoint = 'ipc://' + str(root / 'broker.sock')
|
||||
env = dict(os.environ, HERMES_HOME=str(root / 'home'), HERMES_AGENT_ROOT=str(root), PYTHONDONTWRITEBYTECODE='1')
|
||||
proc = subprocess.Popen([sys.executable, str(entry), '--endpoint', endpoint, '--agent-root', str(root), '--hermes-home', str(root / 'home')], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
try:
|
||||
deadline = time.monotonic() + 15
|
||||
while not (root / 'broker.sock').exists():
|
||||
if proc.poll() is not None or time.monotonic() > deadline:
|
||||
raise RuntimeError('compatibility broker failed to start')
|
||||
time.sleep(0.05)
|
||||
with socket.socket(socket.AF_UNIX) as client:
|
||||
client.settimeout(20)
|
||||
client.connect(str(root / 'broker.sock'))
|
||||
client.sendall(b'{"action":"worker_ping","profile":"default"}\n')
|
||||
with client.makefile('rb') as response:
|
||||
result = json.loads(response.readline())
|
||||
assert result.get('ok') and result.get('pong') and result.get('worker_profile') == 'default', result
|
||||
finally:
|
||||
proc.terminate()
|
||||
try: proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
`
|
||||
execFileSync('python3', ['-c', script], { cwd: process.cwd(), encoding: 'utf8', stdio: 'pipe', timeout: 30_000 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user