mirror of
https://github.com/hanzo-apps/world.git
synced 2026-10-30 14:53:09 +00:00
PART 1 — one introspectable command registry + one dispatcher (decomplected): - src/services/app-commands.ts is the single source of truth: AppHost capability port + 16 typed commands (name, JSON-schema params, human description, run()) + commandManifest() + the ONE dispatch(): gate (sign-in) → validate → execute → visible action log. Full control now covers panels (show/hide/move/resize), map (layers, 2D/3D, fly-to, region, time range), variant, theme, search, reset layout, custom feeds, and org switch — each driving existing App APIs through the port (no reaching into internals). - analyst-actions.ts reduced to a type shim (AnalystHost=AppHost). AnalystChat dispatches through the registry and renders per-command ✓/✗ entries. - Go tool contract DERIVES from the registry: the client sends its manifest with every request; handlers_analyst.go builds the action-contract prompt from it (renderCommandContract) and passes actions through generically, gated by the manifest's type set — the backend no longer duplicates the schema. Embedded mirror data/analyst_commands.json (generated by scripts/gen-analyst-commands.mjs, drift-guarded by TestAnalystCommandManifest) covers callers that omit a manifest. Model dropdown + backend plane (CTO directive): - Chat header carries a monochrome model select (Zen family first, then upstream /v1/models + agents); choice persists and rides every request. New same-origin /v1/world/models proxy; model passes through to inference (chatMessagesModel). "agent:<ref>" ids route to the agents run plane (invoke an agent in-chat). P0 — errors always visible: the chat never shows a blank. An empty/degraded/errored backend reply (e.g. prod's aud=hanzo-world 401) now surfaces the reason inline. PART 2 — transport seam: src/services/analyst-transport.ts isolates the wire; HTTP is the real impl, registerTransport() is the ZAP seam. Verdict: real browser ZAP (@zap-proto/web) exists and is production-wired in hanzo/platform, but world has no ZAP dep and no Go WS-ZAP server, and ZAP has no streaming primitive today — so HTTP stays default, ZAP wiring documented in-file (no fantasy transport). Gates: tsc + vite build + go build + go test ./internal/world/ all pass; Playwright (e2e/analyst-command.spec.ts) proves signed-out sign-in prompt and a signed-in command round-trip that visibly moves a panel + logs the action (screens/). Co-authored-by: Hanzo Dev <[email protected]>
126 lines
5.1 KiB
TypeScript
126 lines
5.1 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
|
|
/**
|
|
* Analyst control-surface e2e: signed-out shows the sign-in prompt (unchanged
|
|
* behaviour), and a signed-in command round-trip — a stubbed AI response carrying
|
|
* a {type:"move_panel"} command flows transport → dispatcher → AppHost and the
|
|
* panel visibly moves, with a ✓ action-log entry in the chat. No real inference:
|
|
* the analyst + models routes are stubbed so the test is deterministic offline.
|
|
*/
|
|
|
|
const SCREENS = 'e2e/screens';
|
|
|
|
// Stub every /v1/world/* call the chat touches so the flow is deterministic.
|
|
async function stubWorldApi(page: Page): Promise<void> {
|
|
await page.route('**/v1/world/**', (route: Route) => {
|
|
const url = route.request().url();
|
|
if (url.includes('/v1/world/analyst')) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
reply: 'Done — moved Markets to the top and switched to the light theme.',
|
|
actions: [
|
|
{ type: 'move_panel', key: 'markets', position: 'top' },
|
|
{ type: 'set_theme', theme: 'light' },
|
|
],
|
|
model: 'zen5',
|
|
tokens: 42,
|
|
}),
|
|
});
|
|
}
|
|
if (url.includes('/v1/world/models')) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
data: [
|
|
{ id: 'zen5', label: 'Zen 5', group: 'Zen' },
|
|
{ id: 'zen5-flash', label: 'Zen 5 Flash', group: 'Zen' },
|
|
{ id: 'zen3-omni', label: 'Zen 3 Omni', group: 'Zen' },
|
|
],
|
|
default: 'zen5',
|
|
}),
|
|
});
|
|
}
|
|
// Everything else (context feeds) → empty, so collectContext degrades quietly.
|
|
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
|
|
});
|
|
}
|
|
|
|
async function signIn(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
localStorage.setItem('hanzo_iam_access_token', 'e2e-fake-token');
|
|
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3_600_000));
|
|
localStorage.setItem('hanzo_iam_owner', 'e2e-org');
|
|
});
|
|
}
|
|
|
|
async function appReady(page: Page): Promise<void> {
|
|
await page.goto('/');
|
|
await page.waitForSelector('#panelsGrid', { timeout: 60_000 });
|
|
await page.waitForSelector('[data-panel="markets"]', { timeout: 60_000 });
|
|
}
|
|
|
|
async function panelOrder(page: Page): Promise<string[]> {
|
|
return page.$$eval('#panelsGrid [data-panel]', (els) =>
|
|
els.map((e) => (e as HTMLElement).dataset.panel || ''),
|
|
);
|
|
}
|
|
|
|
test.describe('AI analyst control surface', () => {
|
|
test('signed-out: opening the dock shows the sign-in prompt (unchanged)', async ({ page }) => {
|
|
await stubWorldApi(page);
|
|
await appReady(page);
|
|
|
|
await page.click('.ai-dock-fab');
|
|
const prompt = page.locator('.ai-dock-body .ai-analyst-signedout');
|
|
await expect(prompt).toBeVisible();
|
|
await expect(prompt.locator('.ai-analyst-signin')).toHaveText(/sign in/i);
|
|
// No chat composer for anonymous users.
|
|
await expect(page.locator('.ai-dock-body .ai-analyst-input')).toHaveCount(0);
|
|
|
|
await page.screenshot({ path: `${SCREENS}/analyst-signedout.png` });
|
|
});
|
|
|
|
test('signed-in: a stubbed command round-trip moves a panel + logs the action', async ({ page }) => {
|
|
await signIn(page);
|
|
await stubWorldApi(page);
|
|
await appReady(page);
|
|
|
|
const before = await panelOrder(page);
|
|
const beforeIdx = before.indexOf('markets');
|
|
expect(beforeIdx).toBeGreaterThan(1); // markets starts well down the grid
|
|
await page.screenshot({ path: `${SCREENS}/round-trip-before.png` });
|
|
|
|
// Open the analyst dock and confirm the model dropdown populated.
|
|
await page.click('.ai-dock-fab');
|
|
const composer = page.locator('.ai-dock-body .ai-analyst-input');
|
|
await expect(composer).toBeVisible();
|
|
const modelSelect = page.locator('.ai-dock-body .ai-analyst-model');
|
|
await expect(modelSelect).toBeVisible();
|
|
await expect(modelSelect.locator('option')).toHaveCount(3);
|
|
await expect(modelSelect).toHaveValue('zen5');
|
|
|
|
// Send a command-style message; the stub returns the move+theme commands.
|
|
await composer.fill('Move markets to the top and go light');
|
|
await page.click('.ai-dock-body .ai-analyst-send');
|
|
|
|
// The prose reply renders…
|
|
await expect(page.locator('.ai-dock-body .ai-analyst-msg.assistant')).toContainText('moved Markets');
|
|
// …and the per-command action log shows a ✓ for the executed move.
|
|
const log = page.locator('.ai-dock-body .ai-analyst-actionlog .ai-analyst-action.ok');
|
|
await expect(log.first()).toBeVisible();
|
|
await expect(log.first()).toContainText(/moved markets/i);
|
|
|
|
// The panel VISIBLY moved: markets is now near the top of the grid.
|
|
await expect
|
|
.poll(async () => (await panelOrder(page)).indexOf('markets'), { timeout: 15_000 })
|
|
.toBeLessThan(beforeIdx);
|
|
// And the theme command took effect (whole-app visible change).
|
|
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light');
|
|
|
|
await page.screenshot({ path: `${SCREENS}/round-trip-after.png` });
|
|
});
|
|
});
|