natnael kahssay commited on
Commit
ce25387
·
1 Parent(s): 38cd72d

feat: use real moav2 source as RL task suite — symlinked sandbox, 3 real service tasks

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Dockerfile +11 -2
  2. moav2/package.json +75 -0
  3. moav2/src/__tests__/agent-buffer-no-model.test.tsx +40 -0
  4. moav2/src/__tests__/agent-buffer-ui.test.tsx +73 -0
  5. moav2/src/__tests__/browser-buffer-capacitor.test.tsx +90 -0
  6. moav2/src/__tests__/browser-history.test.ts +596 -0
  7. moav2/src/__tests__/capacitor-boot.test.ts +56 -0
  8. moav2/src/__tests__/capacitor-browser.test.ts +84 -0
  9. moav2/src/__tests__/capacitor-mini-shell.test.ts +157 -0
  10. moav2/src/__tests__/cli-history.test.ts +505 -0
  11. moav2/src/__tests__/command-palette-utils.test.ts +308 -0
  12. moav2/src/__tests__/command-palette.test.tsx +463 -0
  13. moav2/src/__tests__/dropdown.test.tsx +108 -0
  14. moav2/src/__tests__/event-store.test.ts +483 -0
  15. moav2/src/__tests__/model-resolver-openai-oauth.test.ts +35 -0
  16. moav2/src/__tests__/oauth-code-bridge.test.ts +43 -0
  17. moav2/src/__tests__/pin-system.test.ts +302 -0
  18. moav2/src/__tests__/platform-boot.test.ts +109 -0
  19. moav2/src/__tests__/provider-smoke.test.ts +49 -0
  20. moav2/src/__tests__/retry.test.ts +121 -0
  21. moav2/src/__tests__/scroll-behavior.test.ts +293 -0
  22. moav2/src/__tests__/session-store-waiting.test.ts +24 -0
  23. moav2/src/__tests__/set-system-prompt-tool.test.ts +26 -0
  24. moav2/src/__tests__/terminal-buffer-capacitor.test.tsx +27 -0
  25. moav2/src/__tests__/tools.test.ts +220 -0
  26. moav2/src/__tests__/window-constraints.test.ts +17 -0
  27. moav2/src/cli/db-reader.ts +236 -0
  28. moav2/src/cli/history.ts +127 -0
  29. moav2/src/cli/index.ts +169 -0
  30. moav2/src/core/platform/index.ts +16 -0
  31. moav2/src/core/platform/types.ts +61 -0
  32. moav2/src/core/services/action-logger.ts +45 -0
  33. moav2/src/core/services/agent-service.ts +312 -0
  34. moav2/src/core/services/db.ts +377 -0
  35. moav2/src/core/services/event-store.ts +449 -0
  36. moav2/src/core/services/google-auth.ts +226 -0
  37. moav2/src/core/services/google-vertex-express.ts +372 -0
  38. moav2/src/core/services/model-resolver.ts +91 -0
  39. moav2/src/core/services/oauth-code-bridge.ts +42 -0
  40. moav2/src/core/services/provider-guards.ts +43 -0
  41. moav2/src/core/services/retry.ts +134 -0
  42. moav2/src/core/services/runtime-pack.ts +348 -0
  43. moav2/src/core/services/session-store.ts +911 -0
  44. moav2/src/core/services/session-waiting.ts +14 -0
  45. moav2/src/core/services/terminal-service.ts +225 -0
  46. moav2/src/core/services/tools/index.ts +9 -0
  47. moav2/src/core/tools/bash-tool.ts +42 -0
  48. moav2/src/core/tools/edit-tool.ts +46 -0
  49. moav2/src/core/tools/history-tool.ts +199 -0
  50. moav2/src/core/tools/index.ts +41 -0
Dockerfile CHANGED
@@ -1,8 +1,17 @@
1
  FROM ghcr.io/meta-pytorch/openenv-base:latest
2
 
3
- # Install node for running vitest
4
- RUN apt-get update && apt-get install -y nodejs npm && rm -rf /var/lib/apt/lists/*
 
 
 
5
 
 
 
 
 
 
 
6
  COPY src/core/ /app/src/core/
7
  COPY src/envs/moa_env/ /app/src/envs/moa_env/
8
 
 
1
  FROM ghcr.io/meta-pytorch/openenv-base:latest
2
 
3
+ # Install node 20 + npm
4
+ RUN apt-get update && apt-get install -y curl && \
5
+ curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
6
+ apt-get install -y nodejs && \
7
+ rm -rf /var/lib/apt/lists/*
8
 
9
+ # Copy moav2 source and pre-install all dependencies once
10
+ # (node_modules will be symlinked into per-request sandboxes — no 700MB copy per reset)
11
+ COPY moav2/ /app/moav2/
12
+ RUN cd /app/moav2 && npm install --no-audit --no-fund
13
+
14
+ # Copy env server
15
  COPY src/core/ /app/src/core/
16
  COPY src/envs/moa_env/ /app/src/envs/moa_env/
17
 
moav2/package.json ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "moav2",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev:web": "vite",
7
+ "dev:ios": "npx cap run ios",
8
+ "dev:android": "npx cap run android",
9
+ "build:ios": "npm run build:web && npx cap sync ios",
10
+ "build:android": "npm run build:web && npx cap sync android",
11
+ "mobile:android": "./scripts/android-build-install.sh",
12
+ "dev:electron": "npm run build:main && concurrently --kill-others-on-fail -n compile,renderer,electron \"npm run dev:compile\" \"npm run dev:renderer\" \"npm run dev:electron:run\"",
13
+ "dev:compile": "tsc -p tsconfig.main.json --watch --preserveWatchOutput",
14
+ "dev:renderer": "ELECTRON_RENDERER=1 vite",
15
+ "dev:electron:run": "sleep 3 && unset ELECTRON_RUN_AS_NODE && CLOUDSDK_ACTIVE_CONFIG_NAME=moa-os VITE_DEV_SERVER_URL=http://localhost:14000 electron .",
16
+ "build": "npm run build:main && npm run build:web",
17
+ "build:main": "tsc -p tsconfig.main.json && cp src/electron/preload.cjs dist/electron/",
18
+ "build:web": "vite build",
19
+ "test": "vitest run",
20
+ "test:e2e": "playwright test",
21
+ "test:e2e:headed": "playwright test --headed",
22
+ "test:watch": "vitest",
23
+ "type-check": "tsc --noEmit",
24
+ "history": "npx tsx src/cli/index.ts",
25
+ "postinstall": "electron-rebuild --only node-pty",
26
+ "rebuild:electron": "electron-rebuild --only node-pty"
27
+ },
28
+ "main": "dist/electron/main.js",
29
+ "dependencies": {
30
+ "@anthropic-ai/sdk": "^0.29.0",
31
+ "@capacitor/android": "^8.1.0",
32
+ "@capacitor/browser": "^8.0.1",
33
+ "@capacitor/core": "^8.1.0",
34
+ "@capacitor/ios": "^8.1.0",
35
+ "@capgo/inappbrowser": "^8.1.24",
36
+ "@mariozechner/pi-agent-core": "latest",
37
+ "@mariozechner/pi-ai": "latest",
38
+ "@zenfs/core": "^2.5.0",
39
+ "@zenfs/dom": "^1.2.7",
40
+ "path-browserify": "^1.0.1",
41
+ "react": "^18.2.0",
42
+ "react-dom": "^18.2.0",
43
+ "react-markdown": "^9.0.0",
44
+ "wa-sqlite": "^1.0.0",
45
+ "xterm": "^5.3.0",
46
+ "xterm-addon-fit": "^0.8.0",
47
+ "xterm-addon-search": "^0.13.0",
48
+ "xterm-addon-unicode11": "^0.6.0",
49
+ "xterm-addon-web-links": "^0.9.0",
50
+ "xterm-addon-webgl": "^0.16.0"
51
+ },
52
+ "devDependencies": {
53
+ "@capacitor/cli": "^8.1.0",
54
+ "@electron/rebuild": "^4.0.0",
55
+ "@playwright/test": "^1.54.2",
56
+ "@sinclair/typebox": "^0.34.48",
57
+ "@testing-library/jest-dom": "^6.9.1",
58
+ "@testing-library/react": "^16.3.2",
59
+ "@testing-library/user-event": "^14.6.1",
60
+ "@types/node": "^20.0.0",
61
+ "@types/react": "^18.2.0",
62
+ "@types/react-dom": "^18.2.0",
63
+ "@vitejs/plugin-react": "^4.2.0",
64
+ "concurrently": "^8.2.0",
65
+ "electron": "^40.6.0",
66
+ "fake-indexeddb": "^6.2.5",
67
+ "jsdom": "^28.0.0",
68
+ "node-pty": "^1.1.0",
69
+ "tailwindcss": "^3.3.0",
70
+ "typescript": "^5.3.0",
71
+ "vite": "^5.0.0",
72
+ "vite-plugin-electron-renderer": "^0.14.6",
73
+ "vitest": "^4.0.18"
74
+ }
75
+ }
moav2/src/__tests__/agent-buffer-no-model.test.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { fireEvent, render, screen } from '@testing-library/react'
3
+
4
+ const mockState = {
5
+ messages: [],
6
+ streamingBlocks: [],
7
+ input: '',
8
+ isStreaming: false,
9
+ expandedTools: new Set<string>(),
10
+ agentReady: false,
11
+ isLoading: false,
12
+ model: '',
13
+ sendError: null,
14
+ }
15
+
16
+ vi.mock('../core/services/session-store', () => ({
17
+ sessionStore: {
18
+ subscribe: () => () => {},
19
+ getSession: () => mockState,
20
+ initSession: vi.fn(),
21
+ sendMessage: vi.fn(),
22
+ setInput: vi.fn(),
23
+ toggleTool: vi.fn(),
24
+ clearSendError: vi.fn(),
25
+ },
26
+ }))
27
+
28
+ import AgentBuffer from '../ui/components/AgentBuffer'
29
+
30
+ describe('AgentBuffer model-less session UX', () => {
31
+ it('shows configure provider prompt when model is empty', () => {
32
+ const onModelClick = vi.fn()
33
+ render(<AgentBuffer sessionId="s1" model="" onModelClick={onModelClick} />)
34
+
35
+ expect(screen.getByText(/configure a provider/i)).toBeTruthy()
36
+ const button = screen.getByRole('button', { name: /open settings/i })
37
+ fireEvent.click(button)
38
+ expect(onModelClick).toHaveBeenCalled()
39
+ })
40
+ })
moav2/src/__tests__/agent-buffer-ui.test.tsx ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { cleanup, render, screen } from '@testing-library/react'
3
+ import AgentBuffer from '../ui/components/AgentBuffer'
4
+
5
+ let mockState: any
6
+ const listeners = new Set<() => void>()
7
+
8
+ vi.mock('../core/services/session-store', () => ({
9
+ sessionStore: {
10
+ subscribe: (listener: () => void) => {
11
+ listeners.add(listener)
12
+ return () => listeners.delete(listener)
13
+ },
14
+ getSession: () => mockState,
15
+ initSession: vi.fn(),
16
+ toggleTool: vi.fn(),
17
+ setInput: vi.fn(),
18
+ sendMessage: vi.fn(),
19
+ clearSendError: vi.fn(),
20
+ },
21
+ }))
22
+
23
+ describe('AgentBuffer UI states', () => {
24
+ afterEach(() => {
25
+ cleanup()
26
+ })
27
+
28
+ beforeEach(() => {
29
+ ;(Element.prototype as any).scrollIntoView = vi.fn()
30
+ listeners.clear()
31
+ mockState = {
32
+ messages: [],
33
+ streamingBlocks: [],
34
+ expandedTools: new Set<string>(),
35
+ input: '',
36
+ isLoading: false,
37
+ isStreaming: false,
38
+ isWaitingForResponse: false,
39
+ agentReady: true,
40
+ sendError: '',
41
+ }
42
+ })
43
+
44
+ it('shows only the dot indicator while waiting for response', () => {
45
+ mockState.isWaitingForResponse = true
46
+ mockState.messages = [
47
+ {
48
+ id: 'u1',
49
+ role: 'user',
50
+ blocks: [{ id: 'ub1', type: 'text', content: 'hello' }],
51
+ createdAt: Date.now(),
52
+ },
53
+ ]
54
+
55
+ render(<AgentBuffer sessionId="s1" model="anthropic-oauth:claude-3-7-sonnet" />)
56
+
57
+ expect(screen.getByLabelText('Assistant is thinking')).toBeTruthy()
58
+ expect(screen.queryByText('Waiting for response')).toBeNull()
59
+ })
60
+
61
+ it('shows streaming thinking block instead of waiting indicator once assistant starts', () => {
62
+ mockState.isWaitingForResponse = true
63
+ mockState.isStreaming = true
64
+ mockState.streamingBlocks = [
65
+ { id: 't1', type: 'thinking', content: 'Analyzing request...' },
66
+ ]
67
+
68
+ render(<AgentBuffer sessionId="s1" model="anthropic-oauth:claude-3-7-sonnet" />)
69
+
70
+ expect(screen.queryByLabelText('Assistant is thinking')).toBeNull()
71
+ expect(screen.getByText('Thinking...')).toBeTruthy()
72
+ })
73
+ })
moav2/src/__tests__/browser-buffer-capacitor.test.tsx ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { act, render } from '@testing-library/react'
3
+
4
+ const openExternal = vi.fn()
5
+
6
+ const controllerMock = {
7
+ open: vi.fn(async () => undefined),
8
+ setUrl: vi.fn(async () => undefined),
9
+ goBack: vi.fn(async () => undefined),
10
+ reload: vi.fn(async () => undefined),
11
+ show: vi.fn(async () => undefined),
12
+ hide: vi.fn(async () => undefined),
13
+ close: vi.fn(async () => undefined),
14
+ updateBounds: vi.fn(async () => undefined),
15
+ addEventListener: vi.fn(() => () => undefined),
16
+ }
17
+
18
+ const createController = vi.fn(() => controllerMock)
19
+
20
+ vi.mock('../core/platform', () => ({
21
+ getPlatform: () => ({
22
+ type: 'capacitor',
23
+ shell: { openExternal },
24
+ }),
25
+ }))
26
+
27
+ vi.mock('../platform/capacitor/capacitor-browser', () => ({
28
+ createCapacitorBrowserController: () => createController(),
29
+ }))
30
+
31
+ import BrowserBuffer from '../ui/components/BrowserBuffer'
32
+
33
+ describe('BrowserBuffer capacitor mode', () => {
34
+ beforeAll(() => {
35
+ ;(globalThis as any).ResizeObserver = class {
36
+ observe() {}
37
+ disconnect() {}
38
+ }
39
+ })
40
+
41
+ beforeEach(() => {
42
+ createController.mockClear()
43
+ openExternal.mockClear()
44
+ for (const key of Object.keys(controllerMock) as Array<keyof typeof controllerMock>) {
45
+ controllerMock[key].mockClear()
46
+ }
47
+ })
48
+
49
+ it('creates controller lazily and opens when active', async () => {
50
+ render(<BrowserBuffer id="b1" isActive />)
51
+ await act(async () => {})
52
+
53
+ expect(createController).toHaveBeenCalledTimes(1)
54
+ expect(controllerMock.open).toHaveBeenCalledTimes(1)
55
+ expect(controllerMock.open).toHaveBeenCalledWith('https://www.google.com', expect.objectContaining({ width: 1, height: 1 }))
56
+ })
57
+
58
+ it('hides and shows webview for tab switch and palette events', async () => {
59
+ const { rerender } = render(<BrowserBuffer id="b1" isActive />)
60
+ await act(async () => {})
61
+
62
+ rerender(<BrowserBuffer id="b1" isActive={false} />)
63
+ await act(async () => {})
64
+ expect(controllerMock.hide).toHaveBeenCalled()
65
+
66
+ rerender(<BrowserBuffer id="b1" isActive />)
67
+ await act(async () => {})
68
+ expect(controllerMock.show).toHaveBeenCalled()
69
+ expect(controllerMock.updateBounds).toHaveBeenCalled()
70
+
71
+ act(() => {
72
+ window.dispatchEvent(new CustomEvent('moa:command-palette-open'))
73
+ })
74
+ expect(controllerMock.hide).toHaveBeenCalled()
75
+
76
+ act(() => {
77
+ window.dispatchEvent(new CustomEvent('moa:command-palette-close'))
78
+ })
79
+ expect(controllerMock.show).toHaveBeenCalled()
80
+ })
81
+
82
+ it('closes controller on unmount', async () => {
83
+ const { unmount } = render(<BrowserBuffer id="b1" isActive />)
84
+ await act(async () => {})
85
+
86
+ unmount()
87
+ await act(async () => {})
88
+ expect(controllerMock.close).toHaveBeenCalledTimes(1)
89
+ })
90
+ })
moav2/src/__tests__/browser-history.test.ts ADDED
@@ -0,0 +1,596 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Browser History Persistence tests.
3
+ *
4
+ * Verifies that the DatabaseManager (IndexedDB-based) correctly persists
5
+ * sessions and messages across re-initialization, which simulates a browser
6
+ * page reload. Uses fake-indexeddb to provide IndexedDB in the jsdom test env.
7
+ *
8
+ * Tests cover:
9
+ * - Database initialization
10
+ * - Session CRUD (create, read, list, update, delete)
11
+ * - Message persistence (add, retrieve by session, update, delete)
12
+ * - Session list ordering (most recently updated first)
13
+ * - Message ordering (chronological)
14
+ * - Session deletion cascading to messages
15
+ * - Empty database handling
16
+ * - Data survives re-initialization (simulated page reload)
17
+ */
18
+ import { describe, it, expect, beforeEach } from 'vitest'
19
+ import 'fake-indexeddb/auto'
20
+ import { IDBFactory } from 'fake-indexeddb'
21
+ import { DatabaseManager } from '../core/services/db'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helper: small delay to ensure distinct timestamps
25
+ // ---------------------------------------------------------------------------
26
+ function delay(ms: number): Promise<void> {
27
+ return new Promise(resolve => setTimeout(resolve, ms))
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helper: create a fresh DatabaseManager instance (simulates page reload)
32
+ // ---------------------------------------------------------------------------
33
+ async function createFreshDb(): Promise<DatabaseManager> {
34
+ const mgr = new DatabaseManager()
35
+ await mgr.init()
36
+ return mgr
37
+ }
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tests
41
+ // ---------------------------------------------------------------------------
42
+
43
+ describe('Browser History Persistence (DatabaseManager)', () => {
44
+ let db: DatabaseManager
45
+
46
+ beforeEach(async () => {
47
+ // Reset IndexedDB completely for test isolation
48
+ globalThis.indexedDB = new IDBFactory()
49
+ db = await createFreshDb()
50
+ })
51
+
52
+ // =========================================================================
53
+ // Database Initialization
54
+ // =========================================================================
55
+
56
+ describe('Database Initialization', () => {
57
+ it('initializes without errors', async () => {
58
+ const mgr = new DatabaseManager()
59
+ await expect(mgr.init()).resolves.not.toThrow()
60
+ })
61
+
62
+ it('can be initialized multiple times on the same database (idempotent)', async () => {
63
+ const mgr1 = await createFreshDb()
64
+ const mgr2 = await createFreshDb()
65
+ // Both should work independently on the same underlying DB
66
+ const s1 = await mgr1.createSession('model-a')
67
+ const s2 = await mgr2.createSession('model-b')
68
+ expect(s1.id).toBeTruthy()
69
+ expect(s2.id).toBeTruthy()
70
+ })
71
+ })
72
+
73
+ // =========================================================================
74
+ // Session CRUD
75
+ // =========================================================================
76
+
77
+ describe('Session CRUD', () => {
78
+ it('creates a session with correct fields', async () => {
79
+ const session = await db.createSession('claude-3.5-sonnet')
80
+ expect(session.id).toBeTruthy()
81
+ expect(session.title).toBe('New Chat')
82
+ expect(session.model).toBe('claude-3.5-sonnet')
83
+ expect(session.createdAt).toBeGreaterThan(0)
84
+ expect(session.updatedAt).toBeGreaterThan(0)
85
+ expect(session.createdAt).toBe(session.updatedAt)
86
+ })
87
+
88
+ it('retrieves a session by ID', async () => {
89
+ const created = await db.createSession('model-a')
90
+ const retrieved = await db.getSession(created.id)
91
+ expect(retrieved).not.toBeNull()
92
+ expect(retrieved!.id).toBe(created.id)
93
+ expect(retrieved!.title).toBe('New Chat')
94
+ expect(retrieved!.model).toBe('model-a')
95
+ })
96
+
97
+ it('returns null for non-existent session', async () => {
98
+ const result = await db.getSession('non-existent-id')
99
+ expect(result).toBeNull()
100
+ })
101
+
102
+ it('lists all sessions', async () => {
103
+ await db.createSession('model-a')
104
+ await delay(10)
105
+ await db.createSession('model-b')
106
+ await delay(10)
107
+ await db.createSession('model-c')
108
+
109
+ const sessions = await db.listSessions()
110
+ expect(sessions.length).toBe(3)
111
+ })
112
+
113
+ it('lists sessions sorted by sortOrder ascending (creation date)', async () => {
114
+ const s1 = await db.createSession('model-a')
115
+ await delay(15)
116
+ const s2 = await db.createSession('model-b')
117
+ await delay(15)
118
+ const s3 = await db.createSession('model-c')
119
+
120
+ const sessions = await db.listSessions()
121
+ expect(sessions.length).toBe(3)
122
+ // Sorted by sortOrder ascending (oldest first by default)
123
+ expect(sessions[0].id).toBe(s1.id)
124
+ expect(sessions[1].id).toBe(s2.id)
125
+ expect(sessions[2].id).toBe(s3.id)
126
+ })
127
+
128
+ it('updates session title', async () => {
129
+ const session = await db.createSession('model-a')
130
+ await delay(10)
131
+ const updated = await db.updateSession(session.id, { title: 'My Conversation' })
132
+ expect(updated).not.toBeNull()
133
+ expect(updated!.title).toBe('My Conversation')
134
+ expect(updated!.updatedAt).toBeGreaterThan(session.updatedAt)
135
+ })
136
+
137
+ it('updates session model', async () => {
138
+ const session = await db.createSession('model-a')
139
+ await delay(10)
140
+ const updated = await db.updateSession(session.id, { model: 'model-b' })
141
+ expect(updated).not.toBeNull()
142
+ expect(updated!.model).toBe('model-b')
143
+ })
144
+
145
+ it('updateSession returns null for non-existent session', async () => {
146
+ const result = await db.updateSession('non-existent', { title: 'Nope' })
147
+ expect(result).toBeNull()
148
+ })
149
+
150
+ it('removes a session', async () => {
151
+ const session = await db.createSession('model-a')
152
+ await db.removeSession(session.id)
153
+ const result = await db.getSession(session.id)
154
+ expect(result).toBeNull()
155
+ })
156
+
157
+ it('session ordering is stable after update (sortOrder-based)', async () => {
158
+ const s1 = await db.createSession('model-a')
159
+ await delay(15)
160
+ const s2 = await db.createSession('model-b')
161
+ await delay(15)
162
+
163
+ // s1 created first, so it should be first (sortOrder ascending)
164
+ let sessions = await db.listSessions()
165
+ expect(sessions[0].id).toBe(s1.id)
166
+
167
+ // Updating s1 title does NOT change sortOrder — order remains stable
168
+ await delay(15)
169
+ await db.updateSession(s1.id, { title: 'Updated' })
170
+
171
+ sessions = await db.listSessions()
172
+ expect(sessions[0].id).toBe(s1.id)
173
+ expect(sessions[1].id).toBe(s2.id)
174
+ })
175
+ })
176
+
177
+ // =========================================================================
178
+ // Message Persistence
179
+ // =========================================================================
180
+
181
+ describe('Message Persistence', () => {
182
+ let sessionId: string
183
+
184
+ beforeEach(async () => {
185
+ const session = await db.createSession('test-model')
186
+ sessionId = session.id
187
+ })
188
+
189
+ it('adds a message with correct fields', async () => {
190
+ const msg = await db.addMessage(sessionId, 'user', 'Hello world')
191
+ expect(msg.id).toBeTruthy()
192
+ expect(msg.sessionId).toBe(sessionId)
193
+ expect(msg.role).toBe('user')
194
+ expect(msg.content).toBe('Hello world')
195
+ expect(msg.createdAt).toBeGreaterThan(0)
196
+ })
197
+
198
+ it('retrieves messages by session ID', async () => {
199
+ await db.addMessage(sessionId, 'user', 'First message')
200
+ await delay(5)
201
+ await db.addMessage(sessionId, 'assistant', 'Second message')
202
+
203
+ const messages = await db.getMessages(sessionId)
204
+ expect(messages.length).toBe(2)
205
+ expect(messages[0].content).toBe('First message')
206
+ expect(messages[1].content).toBe('Second message')
207
+ })
208
+
209
+ it('messages are ordered chronologically (createdAt ascending)', async () => {
210
+ await db.addMessage(sessionId, 'user', 'First')
211
+ await delay(10)
212
+ await db.addMessage(sessionId, 'assistant', 'Second')
213
+ await delay(10)
214
+ await db.addMessage(sessionId, 'user', 'Third')
215
+
216
+ const messages = await db.getMessages(sessionId)
217
+ expect(messages.length).toBe(3)
218
+ expect(messages[0].content).toBe('First')
219
+ expect(messages[1].content).toBe('Second')
220
+ expect(messages[2].content).toBe('Third')
221
+ // Verify ordering
222
+ expect(messages[0].createdAt).toBeLessThanOrEqual(messages[1].createdAt)
223
+ expect(messages[1].createdAt).toBeLessThanOrEqual(messages[2].createdAt)
224
+ })
225
+
226
+ it('messages are scoped to their session', async () => {
227
+ const session2 = await db.createSession('model-b')
228
+ await db.addMessage(sessionId, 'user', 'Session 1 message')
229
+ await db.addMessage(session2.id, 'user', 'Session 2 message')
230
+
231
+ const msgs1 = await db.getMessages(sessionId)
232
+ const msgs2 = await db.getMessages(session2.id)
233
+
234
+ expect(msgs1.length).toBe(1)
235
+ expect(msgs1[0].content).toBe('Session 1 message')
236
+ expect(msgs2.length).toBe(1)
237
+ expect(msgs2[0].content).toBe('Session 2 message')
238
+ })
239
+
240
+ it('addMessage updates session updatedAt', async () => {
241
+ const sessionBefore = await db.getSession(sessionId)
242
+ await delay(15)
243
+ await db.addMessage(sessionId, 'user', 'New message')
244
+ const sessionAfter = await db.getSession(sessionId)
245
+
246
+ expect(sessionAfter).not.toBeNull()
247
+ expect(sessionAfter!.updatedAt).toBeGreaterThan(sessionBefore!.updatedAt)
248
+ })
249
+
250
+ it('adds message with blocks', async () => {
251
+ const blocks = [
252
+ { id: 'b1', type: 'text' as const, content: 'Hello' },
253
+ { id: 'b2', type: 'tool' as const, toolName: 'bash', status: 'completed' as const },
254
+ ]
255
+ const msg = await db.addMessage(sessionId, 'assistant', 'text', { blocks })
256
+ expect(msg.blocks).toBeDefined()
257
+ expect(msg.blocks!.length).toBe(2)
258
+ expect(msg.blocks![0].content).toBe('Hello')
259
+ expect(msg.blocks![1].toolName).toBe('bash')
260
+ })
261
+
262
+ it('adds message with partial flag', async () => {
263
+ const msg = await db.addMessage(sessionId, 'assistant', '', { partial: true })
264
+ expect(msg.partial).toBe(true)
265
+ })
266
+
267
+ it('adds message with custom ID', async () => {
268
+ const customId = 'custom-msg-id-123'
269
+ const msg = await db.addMessage(sessionId, 'user', 'With custom ID', { id: customId })
270
+ expect(msg.id).toBe(customId)
271
+ })
272
+
273
+ it('updates message content', async () => {
274
+ const msg = await db.addMessage(sessionId, 'assistant', 'Initial')
275
+ await db.updateMessage(msg.id, { content: 'Updated content' })
276
+
277
+ const messages = await db.getMessages(sessionId)
278
+ expect(messages[0].content).toBe('Updated content')
279
+ })
280
+
281
+ it('updates message partial flag', async () => {
282
+ const msg = await db.addMessage(sessionId, 'assistant', '', { partial: true })
283
+ await db.updateMessage(msg.id, { partial: false })
284
+
285
+ const messages = await db.getMessages(sessionId)
286
+ // partial should be explicitly false
287
+ expect(messages[0].partial).toBe(false)
288
+ })
289
+
290
+ it('removes a message', async () => {
291
+ const msg = await db.addMessage(sessionId, 'user', 'To be deleted')
292
+ expect((await db.getMessages(sessionId)).length).toBe(1)
293
+
294
+ await db.removeMessage(msg.id)
295
+ expect((await db.getMessages(sessionId)).length).toBe(0)
296
+ })
297
+ })
298
+
299
+ // =========================================================================
300
+ // Session Deletion Cascade
301
+ // =========================================================================
302
+
303
+ describe('Session Deletion Cascade', () => {
304
+ it('deleting a session also deletes its messages', async () => {
305
+ const session = await db.createSession('model-a')
306
+ await db.addMessage(session.id, 'user', 'Message 1')
307
+ await db.addMessage(session.id, 'assistant', 'Message 2')
308
+ await db.addMessage(session.id, 'user', 'Message 3')
309
+
310
+ // Verify messages exist
311
+ expect((await db.getMessages(session.id)).length).toBe(3)
312
+
313
+ // Delete session
314
+ await db.removeSession(session.id)
315
+
316
+ // Session should be gone
317
+ expect(await db.getSession(session.id)).toBeNull()
318
+
319
+ // Messages should also be gone
320
+ expect((await db.getMessages(session.id)).length).toBe(0)
321
+ })
322
+
323
+ it('deleting a session does not affect other sessions messages', async () => {
324
+ const s1 = await db.createSession('model-a')
325
+ const s2 = await db.createSession('model-b')
326
+ await db.addMessage(s1.id, 'user', 'S1 message')
327
+ await db.addMessage(s2.id, 'user', 'S2 message')
328
+
329
+ // Delete s1
330
+ await db.removeSession(s1.id)
331
+
332
+ // s2 messages should be intact
333
+ const s2Messages = await db.getMessages(s2.id)
334
+ expect(s2Messages.length).toBe(1)
335
+ expect(s2Messages[0].content).toBe('S2 message')
336
+ })
337
+ })
338
+
339
+ // =========================================================================
340
+ // Empty Database Handling
341
+ // =========================================================================
342
+
343
+ describe('Empty Database Handling', () => {
344
+ it('listSessions returns empty array when no sessions exist', async () => {
345
+ const sessions = await db.listSessions()
346
+ expect(sessions).toEqual([])
347
+ })
348
+
349
+ it('getMessages returns empty array for non-existent session', async () => {
350
+ const messages = await db.getMessages('non-existent-session-id')
351
+ expect(messages).toEqual([])
352
+ })
353
+
354
+ it('getMessages returns empty array for session with no messages', async () => {
355
+ const session = await db.createSession('model-a')
356
+ const messages = await db.getMessages(session.id)
357
+ expect(messages).toEqual([])
358
+ })
359
+ })
360
+
361
+ // =========================================================================
362
+ // Data Survives Re-initialization (Simulated Page Reload)
363
+ // =========================================================================
364
+
365
+ describe('Persistence Across Re-initialization', () => {
366
+ it('sessions survive re-initialization', async () => {
367
+ const session = await db.createSession('model-a')
368
+ await db.updateSession(session.id, { title: 'Persistent Chat' })
369
+
370
+ // Simulate page reload: create a new DatabaseManager on the same IndexedDB
371
+ const db2 = await createFreshDb()
372
+
373
+ const sessions = await db2.listSessions()
374
+ expect(sessions.length).toBe(1)
375
+ expect(sessions[0].id).toBe(session.id)
376
+ expect(sessions[0].title).toBe('Persistent Chat')
377
+ expect(sessions[0].model).toBe('model-a')
378
+ })
379
+
380
+ it('messages survive re-initialization', async () => {
381
+ const session = await db.createSession('model-a')
382
+ await db.addMessage(session.id, 'user', 'Hello!')
383
+ await delay(5)
384
+ await db.addMessage(session.id, 'assistant', 'Hi there!')
385
+
386
+ // Simulate page reload
387
+ const db2 = await createFreshDb()
388
+
389
+ const messages = await db2.getMessages(session.id)
390
+ expect(messages.length).toBe(2)
391
+ expect(messages[0].role).toBe('user')
392
+ expect(messages[0].content).toBe('Hello!')
393
+ expect(messages[1].role).toBe('assistant')
394
+ expect(messages[1].content).toBe('Hi there!')
395
+ })
396
+
397
+ it('multiple sessions and messages survive re-initialization', async () => {
398
+ const s1 = await db.createSession('model-a')
399
+ const s2 = await db.createSession('model-b')
400
+ await db.addMessage(s1.id, 'user', 'S1 msg 1')
401
+ await db.addMessage(s1.id, 'assistant', 'S1 msg 2')
402
+ await db.addMessage(s2.id, 'user', 'S2 msg 1')
403
+
404
+ // Simulate page reload
405
+ const db2 = await createFreshDb()
406
+
407
+ const sessions = await db2.listSessions()
408
+ expect(sessions.length).toBe(2)
409
+
410
+ const s1msgs = await db2.getMessages(s1.id)
411
+ const s2msgs = await db2.getMessages(s2.id)
412
+ expect(s1msgs.length).toBe(2)
413
+ expect(s2msgs.length).toBe(1)
414
+ })
415
+
416
+ it('message blocks survive re-initialization', async () => {
417
+ const session = await db.createSession('model-a')
418
+ const blocks = [
419
+ { id: 'blk-1', type: 'text' as const, content: 'Markdown content here' },
420
+ { id: 'blk-2', type: 'tool' as const, toolName: 'read', args: { path: '/a.txt' }, status: 'completed' as const, result: 'file contents' },
421
+ ]
422
+ await db.addMessage(session.id, 'assistant', 'text', { blocks })
423
+
424
+ // Simulate page reload
425
+ const db2 = await createFreshDb()
426
+
427
+ const messages = await db2.getMessages(session.id)
428
+ expect(messages.length).toBe(1)
429
+ expect(messages[0].blocks).toBeDefined()
430
+ expect(messages[0].blocks!.length).toBe(2)
431
+ expect(messages[0].blocks![0].content).toBe('Markdown content here')
432
+ expect(messages[0].blocks![1].toolName).toBe('read')
433
+ expect(messages[0].blocks![1].result).toBe('file contents')
434
+ })
435
+
436
+ it('session ordering is preserved after re-initialization', async () => {
437
+ const s1 = await db.createSession('model-a')
438
+ await delay(15)
439
+ const s2 = await db.createSession('model-b')
440
+ await delay(15)
441
+ const s3 = await db.createSession('model-c')
442
+
443
+ // Simulate page reload
444
+ const db2 = await createFreshDb()
445
+
446
+ const sessions = await db2.listSessions()
447
+ // Sorted by sortOrder ascending (oldest first)
448
+ expect(sessions[0].id).toBe(s1.id)
449
+ expect(sessions[1].id).toBe(s2.id)
450
+ expect(sessions[2].id).toBe(s3.id)
451
+ })
452
+
453
+ it('deleted sessions stay deleted after re-initialization', async () => {
454
+ const s1 = await db.createSession('model-a')
455
+ const s2 = await db.createSession('model-b')
456
+ await db.addMessage(s1.id, 'user', 'msg')
457
+ await db.removeSession(s1.id)
458
+
459
+ // Simulate page reload
460
+ const db2 = await createFreshDb()
461
+
462
+ const sessions = await db2.listSessions()
463
+ expect(sessions.length).toBe(1)
464
+ expect(sessions[0].id).toBe(s2.id)
465
+
466
+ // s1 messages should also be gone
467
+ const s1msgs = await db2.getMessages(s1.id)
468
+ expect(s1msgs.length).toBe(0)
469
+ })
470
+ })
471
+
472
+ // =========================================================================
473
+ // Provider Operations (Basic Verification)
474
+ // =========================================================================
475
+
476
+ describe('Provider Operations', () => {
477
+ it('adds and lists providers', async () => {
478
+ const provider = await db.addProvider('TestProvider', 'https://api.test.com', 'test-key-123')
479
+ expect(provider.id).toBeTruthy()
480
+ expect(provider.name).toBe('TestProvider')
481
+ expect(provider.baseUrl).toBe('https://api.test.com')
482
+
483
+ const providers = await db.listProviders()
484
+ expect(providers.length).toBe(1)
485
+ expect(providers[0].name).toBe('TestProvider')
486
+ })
487
+
488
+ it('removes trailing slash from baseUrl', async () => {
489
+ const provider = await db.addProvider('Test', 'https://api.test.com///', 'key')
490
+ expect(provider.baseUrl).toBe('https://api.test.com')
491
+ })
492
+
493
+ it('providers survive re-initialization', async () => {
494
+ await db.addProvider('Persistent', 'https://api.persist.com', 'key')
495
+
496
+ const db2 = await createFreshDb()
497
+ const providers = await db2.listProviders()
498
+ expect(providers.length).toBe(1)
499
+ expect(providers[0].name).toBe('Persistent')
500
+ })
501
+ })
502
+
503
+ // =========================================================================
504
+ // OAuth Credentials (Basic Verification)
505
+ // =========================================================================
506
+
507
+ describe('OAuth Credentials', () => {
508
+ it('stores and retrieves OAuth credentials', async () => {
509
+ await db.setOAuthCredentials('anthropic', {
510
+ refresh: 'refresh-token',
511
+ access: 'access-token',
512
+ expires: Date.now() + 3600000,
513
+ })
514
+
515
+ const creds = await db.getOAuthCredentials('anthropic')
516
+ expect(creds).not.toBeNull()
517
+ expect(creds!.refresh).toBe('refresh-token')
518
+ expect(creds!.access).toBe('access-token')
519
+ })
520
+
521
+ it('removes OAuth credentials', async () => {
522
+ await db.setOAuthCredentials('anthropic', {
523
+ refresh: 'r', access: 'a', expires: 0,
524
+ })
525
+ await db.removeOAuthCredentials('anthropic')
526
+ const creds = await db.getOAuthCredentials('anthropic')
527
+ expect(creds).toBeNull()
528
+ })
529
+
530
+ it('OAuth credentials survive re-initialization', async () => {
531
+ await db.setOAuthCredentials('google', {
532
+ refresh: 'g-refresh', access: 'g-access', expires: 9999999,
533
+ })
534
+
535
+ const db2 = await createFreshDb()
536
+ const creds = await db2.getOAuthCredentials('google')
537
+ expect(creds).not.toBeNull()
538
+ expect(creds!.refresh).toBe('g-refresh')
539
+ })
540
+ })
541
+
542
+ // =========================================================================
543
+ // Edge Cases
544
+ // =========================================================================
545
+
546
+ describe('Edge Cases', () => {
547
+ it('handles many sessions', async () => {
548
+ const count = 20
549
+ for (let i = 0; i < count; i++) {
550
+ await db.createSession(`model-${i}`)
551
+ }
552
+ const sessions = await db.listSessions()
553
+ expect(sessions.length).toBe(count)
554
+ })
555
+
556
+ it('handles many messages in one session', async () => {
557
+ const session = await db.createSession('model-a')
558
+ const count = 50
559
+ for (let i = 0; i < count; i++) {
560
+ await db.addMessage(session.id, i % 2 === 0 ? 'user' : 'assistant', `Message ${i}`)
561
+ }
562
+ const messages = await db.getMessages(session.id)
563
+ expect(messages.length).toBe(count)
564
+ // Verify chronological ordering
565
+ for (let i = 1; i < messages.length; i++) {
566
+ expect(messages[i].createdAt).toBeGreaterThanOrEqual(messages[i - 1].createdAt)
567
+ }
568
+ })
569
+
570
+ it('handles empty string content in messages', async () => {
571
+ const session = await db.createSession('model-a')
572
+ const msg = await db.addMessage(session.id, 'assistant', '')
573
+ expect(msg.content).toBe('')
574
+
575
+ const messages = await db.getMessages(session.id)
576
+ expect(messages[0].content).toBe('')
577
+ })
578
+
579
+ it('handles special characters in session title', async () => {
580
+ const session = await db.createSession('model-a')
581
+ await db.updateSession(session.id, { title: 'Session with "quotes" & <tags> and unicode: \u{1F680}' })
582
+
583
+ const retrieved = await db.getSession(session.id)
584
+ expect(retrieved!.title).toBe('Session with "quotes" & <tags> and unicode: \u{1F680}')
585
+ })
586
+
587
+ it('handles special characters in message content', async () => {
588
+ const session = await db.createSession('model-a')
589
+ const content = '```javascript\nconsole.log("hello")\n```\n\nUnicode: \u{1F600} \nHTML: <div class="test">&amp;</div>'
590
+ await db.addMessage(session.id, 'user', content)
591
+
592
+ const messages = await db.getMessages(session.id)
593
+ expect(messages[0].content).toBe(content)
594
+ })
595
+ })
596
+ })
moav2/src/__tests__/capacitor-boot.test.ts ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeAll, vi } from 'vitest'
2
+ import 'fake-indexeddb/auto'
3
+ import { setPlatform, getPlatform } from '../core/platform'
4
+ import { createCapacitorPlatform } from '../platform/capacitor'
5
+
6
+ // Mock capacitor environment globals if necessary
7
+ vi.mock('@capacitor/browser', () => ({
8
+ Browser: {
9
+ open: vi.fn().mockResolvedValue(undefined)
10
+ }
11
+ }))
12
+
13
+ describe('Platform Boot (Capacitor)', () => {
14
+ beforeAll(async () => {
15
+ // We assume createCapacitorPlatform handles its own setup.
16
+ // For now, it might reuse some browser components (like ZenFS)
17
+ // or provide its own capacitor-specific implementations.
18
+ const platform = await createCapacitorPlatform()
19
+ setPlatform(platform)
20
+ })
21
+
22
+ it('initializes capacitor platform', () => {
23
+ expect(getPlatform().type).toBe('capacitor')
24
+ })
25
+
26
+ it('has filesystem with read/write', async () => {
27
+ const { fs } = getPlatform()
28
+ fs.mkdirSync('/test-cap', { recursive: true })
29
+ fs.writeFileSync('/test-cap/hello.txt', 'world')
30
+ expect(fs.readFileSync('/test-cap/hello.txt', 'utf-8')).toBe('world')
31
+ })
32
+
33
+ it('has filesystem async read/write', async () => {
34
+ const { fs } = getPlatform()
35
+ fs.mkdirSync('/test-cap', { recursive: true })
36
+ await fs.writeFile('/test-cap/hello-async.txt', 'world-async')
37
+ const content = await fs.readFile('/test-cap/hello-async.txt', 'utf-8')
38
+ expect(content).toBe('world-async')
39
+ })
40
+
41
+ it('has process stub', () => {
42
+ const { process } = getPlatform()
43
+ expect(process.cwd()).toBe('/')
44
+ expect(typeof process.env).toBe('object')
45
+ })
46
+
47
+ it('has shell with openExternal', () => {
48
+ expect(getPlatform().shell.openExternal).toBeDefined()
49
+ expect(typeof getPlatform().shell.openExternal).toBe('function')
50
+ })
51
+
52
+ it('has sqlite interface', () => {
53
+ expect(getPlatform().sqlite).toBeDefined()
54
+ expect(typeof getPlatform().sqlite.open).toBe('function')
55
+ })
56
+ })
moav2/src/__tests__/capacitor-browser.test.ts ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createCapacitorBrowserController } from '../platform/capacitor/capacitor-browser'
3
+
4
+ describe('capacitor browser controller', () => {
5
+ const listeners = new Map<string, (event: any) => void>()
6
+
7
+ const plugin = {
8
+ openWebView: vi.fn(async () => ({ id: 'wv-1' })),
9
+ setUrl: vi.fn(async () => ({})),
10
+ goBack: vi.fn(async () => ({})),
11
+ reload: vi.fn(async () => ({})),
12
+ show: vi.fn(async () => undefined),
13
+ hide: vi.fn(async () => undefined),
14
+ close: vi.fn(async () => ({})),
15
+ updateDimensions: vi.fn(async () => undefined),
16
+ addListener: vi.fn(async (eventName: string, listener: (event: any) => void) => {
17
+ listeners.set(eventName, listener)
18
+ return { remove: vi.fn(async () => undefined) }
19
+ }),
20
+ }
21
+
22
+ beforeEach(() => {
23
+ listeners.clear()
24
+ for (const value of Object.values(plugin)) {
25
+ value.mockClear()
26
+ }
27
+ ;(window as any).__moaCapgoInAppBrowser = { InAppBrowser: plugin }
28
+ })
29
+
30
+ it('opens webview and proxies controller methods', async () => {
31
+ const controller = createCapacitorBrowserController()
32
+ await controller.open('https://example.com', { x: 10, y: 20, width: 200, height: 300 })
33
+
34
+ expect(plugin.openWebView).toHaveBeenCalledWith(expect.objectContaining({
35
+ url: 'https://example.com',
36
+ x: 10,
37
+ y: 20,
38
+ width: 200,
39
+ height: 300,
40
+ toolbarType: 'navigation',
41
+ }))
42
+
43
+ await controller.setUrl('https://next.example.com')
44
+ await controller.goBack()
45
+ await controller.reload()
46
+ await controller.hide()
47
+ await controller.show()
48
+ await controller.updateBounds({ x: 1, y: 2, width: 3, height: 4 })
49
+ await controller.close()
50
+
51
+ expect(plugin.setUrl).toHaveBeenCalledWith({ id: 'wv-1', url: 'https://next.example.com' })
52
+ expect(plugin.goBack).toHaveBeenCalledWith({ id: 'wv-1' })
53
+ expect(plugin.reload).toHaveBeenCalledWith({ id: 'wv-1' })
54
+ expect(plugin.hide).toHaveBeenCalled()
55
+ expect(plugin.show).toHaveBeenCalled()
56
+ expect(plugin.updateDimensions).toHaveBeenCalledWith({ id: 'wv-1', x: 1, y: 2, width: 3, height: 4 })
57
+ expect(plugin.close).toHaveBeenCalledWith({ id: 'wv-1' })
58
+ })
59
+
60
+ it('emits url, loaded, error, and close events', async () => {
61
+ const controller = createCapacitorBrowserController()
62
+ const onUrl = vi.fn()
63
+ const onLoaded = vi.fn()
64
+ const onError = vi.fn()
65
+ const onClose = vi.fn()
66
+
67
+ controller.addEventListener('url', onUrl)
68
+ controller.addEventListener('loaded', onLoaded)
69
+ controller.addEventListener('error', onError)
70
+ controller.addEventListener('close', onClose)
71
+
72
+ await controller.open('https://example.com')
73
+
74
+ listeners.get('urlChangeEvent')?.({ id: 'wv-1', url: 'https://changed.example.com' })
75
+ listeners.get('browserPageLoaded')?.({ id: 'wv-1' })
76
+ listeners.get('pageLoadError')?.({ id: 'wv-1', message: 'bad gateway' })
77
+ listeners.get('closeEvent')?.({ id: 'wv-1' })
78
+
79
+ expect(onUrl).toHaveBeenCalledWith({ url: 'https://changed.example.com' })
80
+ expect(onLoaded).toHaveBeenCalledWith({})
81
+ expect(onError).toHaveBeenCalledWith({ message: 'bad gateway' })
82
+ expect(onClose).toHaveBeenCalledWith({})
83
+ })
84
+ })
moav2/src/__tests__/capacitor-mini-shell.test.ts ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { createCapacitorMiniShell } from '../platform/capacitor/capacitor-mini-shell'
3
+ import { setPlatform } from '../core/platform'
4
+ import path from 'path'
5
+
6
+ function createMemoryFs() {
7
+ const files = new Map<string, string>()
8
+ const dirs = new Set<string>(['/'])
9
+
10
+ const normalize = (p: string) => path.posix.resolve('/', p)
11
+
12
+ const ensureDir = (p: string) => {
13
+ const target = normalize(p)
14
+ const stack: string[] = []
15
+ let cursor = target
16
+ while (!dirs.has(cursor) && cursor !== '/') {
17
+ stack.push(cursor)
18
+ cursor = path.posix.dirname(cursor)
19
+ }
20
+ dirs.add('/')
21
+ while (stack.length > 0) {
22
+ dirs.add(stack.pop() as string)
23
+ }
24
+ }
25
+
26
+ return {
27
+ readFile: async (p: string) => files.get(normalize(p)) || '',
28
+ readFileSync: (p: string) => files.get(normalize(p)) || '',
29
+ writeFile: async (p: string, content: string) => {
30
+ const n = normalize(p)
31
+ ensureDir(path.posix.dirname(n))
32
+ files.set(n, content)
33
+ },
34
+ writeFileSync: (p: string, content: string) => {
35
+ const n = normalize(p)
36
+ ensureDir(path.posix.dirname(n))
37
+ files.set(n, content)
38
+ },
39
+ existsSync: (p: string) => {
40
+ const n = normalize(p)
41
+ return files.has(n) || dirs.has(n)
42
+ },
43
+ mkdirSync: (p: string) => {
44
+ ensureDir(p)
45
+ },
46
+ readdirSync: (p: string) => {
47
+ const n = normalize(p)
48
+ const out: string[] = []
49
+ for (const dir of dirs) {
50
+ if (dir !== n && path.posix.dirname(dir) === n) out.push(path.posix.basename(dir))
51
+ }
52
+ for (const file of files.keys()) {
53
+ if (path.posix.dirname(file) === n) out.push(path.posix.basename(file))
54
+ }
55
+ return out.sort()
56
+ },
57
+ statSync: (p: string) => {
58
+ const n = normalize(p)
59
+ return {
60
+ isFile: () => files.has(n),
61
+ isDirectory: () => dirs.has(n),
62
+ size: files.get(n)?.length || 0,
63
+ }
64
+ },
65
+ unlinkSync: (p: string) => {
66
+ files.delete(normalize(p))
67
+ },
68
+ }
69
+ }
70
+
71
+ describe('capacitor mini shell', () => {
72
+ let shell: ReturnType<typeof createCapacitorMiniShell>
73
+ const execSpy = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
74
+ const execSyncSpy = vi.fn(() => '')
75
+
76
+ beforeAll(async () => {
77
+ const fs = createMemoryFs()
78
+ setPlatform({
79
+ type: 'capacitor',
80
+ fs: fs as any,
81
+ path: path.posix as any,
82
+ process: {
83
+ exec: execSpy,
84
+ execSync: execSyncSpy,
85
+ cwd: () => '/',
86
+ env: {},
87
+ homedir: () => '/',
88
+ },
89
+ sqlite: { open: async () => ({ exec: async () => {}, prepare: () => ({ run: async () => {}, get: async () => ({}), all: async () => [] }), close: async () => {} }) },
90
+ shell: { openExternal: () => {} },
91
+ } as any)
92
+ })
93
+
94
+ beforeEach(() => {
95
+ shell = createCapacitorMiniShell()
96
+ })
97
+
98
+ it('supports pwd and cd', async () => {
99
+ const pwd = await shell.execute('pwd')
100
+ expect(pwd.output).toBe('/')
101
+
102
+ await shell.execute('mkdir docs')
103
+ const cd = await shell.execute('cd docs')
104
+ expect(cd.output).toBe('')
105
+
106
+ const pwd2 = await shell.execute('pwd')
107
+ expect(pwd2.output).toBe('/docs')
108
+
109
+ const badCd = await shell.execute('cd missing')
110
+ expect(badCd.output).toContain('no such file or directory')
111
+ expect(shell.getCwd()).toBe('/docs')
112
+ })
113
+
114
+ it('supports touch, ls, cat, echo, rm', async () => {
115
+ await shell.execute('mkdir notes')
116
+ await shell.execute('cd notes')
117
+ await shell.execute('touch todo.txt')
118
+ await shell.execute('mkdir dir-a')
119
+
120
+ const ls = await shell.execute('ls')
121
+ expect(ls.output).toContain('todo.txt')
122
+ expect(ls.output).toContain('dir-a/')
123
+
124
+ const echo = await shell.execute('echo hello')
125
+ expect(echo.output).toBe('hello')
126
+
127
+ const echoQuoted = await shell.execute('echo "hello world"')
128
+ expect(echoQuoted.output).toBe('hello world')
129
+
130
+ const cat = await shell.execute('cat todo.txt')
131
+ expect(cat.output).toBe('')
132
+
133
+ await shell.execute('touch nested/a/b.txt')
134
+ const nested = await shell.execute('ls nested')
135
+ expect(nested.output).toContain('a/')
136
+
137
+ const rm = await shell.execute('rm todo.txt')
138
+ expect(rm.output).toBe('')
139
+
140
+ const rmDir = await shell.execute('rm dir-a')
141
+ expect(rmDir.output).toContain('Is a directory')
142
+ })
143
+
144
+ it('supports help, clear and unknown commands', async () => {
145
+ const help = await shell.execute('help')
146
+ expect(help.output).toContain('print current directory')
147
+
148
+ const clear = await shell.execute('clear')
149
+ expect(clear.clear).toBe(true)
150
+
151
+ const unknown = await shell.execute('nope')
152
+ expect(unknown.output).toContain('command not found: nope')
153
+
154
+ expect(execSpy).not.toHaveBeenCalled()
155
+ expect(execSyncSpy).not.toHaveBeenCalled()
156
+ })
157
+ })
moav2/src/__tests__/cli-history.test.ts ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * CLI History tests — TDD tests for the terminal chat history CLI tool.
3
+ *
4
+ * Tests the database reader, command formatting (list, view, search),
5
+ * and edge cases (empty DB, missing sessions, partial messages, JSON output).
6
+ *
7
+ * Uses the configured SQLite adapter in in-memory mode for fast, real-fidelity testing.
8
+ */
9
+
10
+ // @vitest-environment node
11
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
12
+ import {
13
+ HistoryDbReader,
14
+ type CliSession,
15
+ type CliMessage,
16
+ } from '../cli/db-reader'
17
+ import {
18
+ formatSessionList,
19
+ formatSessionView,
20
+ formatSearchResults,
21
+ } from '../cli/history'
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Helper: seed test data into a HistoryDbReader's underlying database
25
+ // ---------------------------------------------------------------------------
26
+
27
+ async function createTestReader(): Promise<HistoryDbReader> {
28
+ return await HistoryDbReader.create(':memory:')
29
+ }
30
+
31
+ async function seedSessions(reader: HistoryDbReader, count: number): Promise<CliSession[]> {
32
+ const sessions: CliSession[] = []
33
+ for (let i = 0; i < count; i++) {
34
+ const session: CliSession = {
35
+ id: `session-${i + 1}`,
36
+ title: `Chat ${i + 1}`,
37
+ model: `claude-3-${i % 2 === 0 ? 'opus' : 'sonnet'}`,
38
+ createdAt: 1700000000000 + i * 3600000,
39
+ updatedAt: 1700000000000 + i * 3600000 + 1800000,
40
+ }
41
+ await reader.insertSession(session)
42
+ sessions.push(session)
43
+ }
44
+ return sessions
45
+ }
46
+
47
+ async function seedMessages(
48
+ reader: HistoryDbReader,
49
+ sessionId: string,
50
+ messages: Array<{ role: string; content: string; partial?: boolean }>
51
+ ): Promise<CliMessage[]> {
52
+ const result: CliMessage[] = []
53
+ for (let i = 0; i < messages.length; i++) {
54
+ const msg: CliMessage = {
55
+ id: `msg-${sessionId}-${i + 1}`,
56
+ sessionId,
57
+ role: messages[i].role as 'user' | 'assistant' | 'system',
58
+ content: messages[i].content,
59
+ partial: messages[i].partial ? 1 : 0,
60
+ createdAt: 1700000000000 + i * 60000,
61
+ }
62
+ await reader.insertMessage(msg)
63
+ result.push(msg)
64
+ }
65
+ return result
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Database Reader Tests
70
+ // ---------------------------------------------------------------------------
71
+
72
+ describe('HistoryDbReader', () => {
73
+ let reader: HistoryDbReader
74
+
75
+ beforeEach(async () => {
76
+ reader = await createTestReader()
77
+ })
78
+
79
+ afterEach(async () => {
80
+ await reader.close()
81
+ })
82
+
83
+ it('creates tables on initialization', async () => {
84
+ // If we can list sessions without error, tables exist
85
+ const sessions = await reader.listSessions()
86
+ expect(sessions).toEqual([])
87
+ })
88
+
89
+ it('lists sessions sorted by updatedAt descending', async () => {
90
+ await seedSessions(reader, 3)
91
+ const sessions = await reader.listSessions()
92
+ expect(sessions).toHaveLength(3)
93
+ // Most recently updated first
94
+ expect(sessions[0].id).toBe('session-3')
95
+ expect(sessions[1].id).toBe('session-2')
96
+ expect(sessions[2].id).toBe('session-1')
97
+ })
98
+
99
+ it('lists sessions with limit', async () => {
100
+ await seedSessions(reader, 5)
101
+ const sessions = await reader.listSessions(2)
102
+ expect(sessions).toHaveLength(2)
103
+ expect(sessions[0].id).toBe('session-5')
104
+ expect(sessions[1].id).toBe('session-4')
105
+ })
106
+
107
+ it('gets a session by ID', async () => {
108
+ await seedSessions(reader, 2)
109
+ const session = await reader.getSession('session-1')
110
+ expect(session).toBeDefined()
111
+ expect(session!.title).toBe('Chat 1')
112
+ expect(session!.model).toBe('claude-3-opus')
113
+ })
114
+
115
+ it('returns null for non-existent session', async () => {
116
+ const session = await reader.getSession('nonexistent')
117
+ expect(session).toBeNull()
118
+ })
119
+
120
+ it('gets messages for a session sorted by createdAt ascending', async () => {
121
+ await seedSessions(reader, 1)
122
+ await seedMessages(reader, 'session-1', [
123
+ { role: 'user', content: 'Hello' },
124
+ { role: 'assistant', content: 'Hi there!' },
125
+ { role: 'user', content: 'How are you?' },
126
+ ])
127
+
128
+ const messages = await reader.getMessages('session-1')
129
+ expect(messages).toHaveLength(3)
130
+ expect(messages[0].content).toBe('Hello')
131
+ expect(messages[1].content).toBe('Hi there!')
132
+ expect(messages[2].content).toBe('How are you?')
133
+ })
134
+
135
+ it('filters out partial messages', async () => {
136
+ await seedSessions(reader, 1)
137
+ await seedMessages(reader, 'session-1', [
138
+ { role: 'user', content: 'Hello' },
139
+ { role: 'assistant', content: 'Partial response...', partial: true },
140
+ { role: 'assistant', content: 'Complete response' },
141
+ ])
142
+
143
+ const messages = await reader.getMessages('session-1')
144
+ expect(messages).toHaveLength(2)
145
+ expect(messages[0].content).toBe('Hello')
146
+ expect(messages[1].content).toBe('Complete response')
147
+ })
148
+
149
+ it('filters messages by role', async () => {
150
+ await seedSessions(reader, 1)
151
+ await seedMessages(reader, 'session-1', [
152
+ { role: 'user', content: 'Q1' },
153
+ { role: 'assistant', content: 'A1' },
154
+ { role: 'user', content: 'Q2' },
155
+ { role: 'assistant', content: 'A2' },
156
+ ])
157
+
158
+ const userMsgs = await reader.getMessages('session-1', { role: 'user' })
159
+ expect(userMsgs).toHaveLength(2)
160
+ expect(userMsgs.every(m => m.role === 'user')).toBe(true)
161
+ })
162
+
163
+ it('limits messages', async () => {
164
+ await seedSessions(reader, 1)
165
+ await seedMessages(reader, 'session-1', [
166
+ { role: 'user', content: 'Msg 1' },
167
+ { role: 'assistant', content: 'Msg 2' },
168
+ { role: 'user', content: 'Msg 3' },
169
+ { role: 'assistant', content: 'Msg 4' },
170
+ { role: 'user', content: 'Msg 5' },
171
+ ])
172
+
173
+ const messages = await reader.getMessages('session-1', { limit: 2 })
174
+ // Limit takes the last N messages
175
+ expect(messages).toHaveLength(2)
176
+ expect(messages[0].content).toBe('Msg 4')
177
+ expect(messages[1].content).toBe('Msg 5')
178
+ })
179
+
180
+ it('returns empty array for messages of non-existent session', async () => {
181
+ const messages = await reader.getMessages('nonexistent')
182
+ expect(messages).toEqual([])
183
+ })
184
+
185
+ it('searches messages case-insensitively', async () => {
186
+ await seedSessions(reader, 2)
187
+ await seedMessages(reader, 'session-1', [
188
+ { role: 'user', content: 'Fix the LOGIN bug' },
189
+ { role: 'assistant', content: 'I will check the login handler' },
190
+ ])
191
+ await seedMessages(reader, 'session-2', [
192
+ { role: 'user', content: 'Refactor the auth module' },
193
+ ])
194
+
195
+ const results = await reader.searchMessages('login')
196
+ expect(results).toHaveLength(2)
197
+ // Results are ordered by createdAt DESC, so the assistant message (later timestamp) comes first
198
+ const contents = results.map(r => r.content)
199
+ expect(contents.some(c => c.includes('LOGIN'))).toBe(true)
200
+ expect(contents.some(c => c.includes('login handler'))).toBe(true)
201
+ })
202
+
203
+ it('searches messages with session filter', async () => {
204
+ await seedSessions(reader, 2)
205
+ await seedMessages(reader, 'session-1', [
206
+ { role: 'user', content: 'Fix the login bug' },
207
+ ])
208
+ await seedMessages(reader, 'session-2', [
209
+ { role: 'user', content: 'The login page is broken' },
210
+ ])
211
+
212
+ const results = await reader.searchMessages('login', { sessionId: 'session-1' })
213
+ expect(results).toHaveLength(1)
214
+ expect(results[0].sessionId).toBe('session-1')
215
+ })
216
+
217
+ it('searches messages with limit', async () => {
218
+ await seedSessions(reader, 1)
219
+ await seedMessages(reader, 'session-1', [
220
+ { role: 'user', content: 'the first message' },
221
+ { role: 'assistant', content: 'the second message' },
222
+ { role: 'user', content: 'the third message' },
223
+ ])
224
+
225
+ const results = await reader.searchMessages('the', { limit: 2 })
226
+ expect(results).toHaveLength(2)
227
+ })
228
+
229
+ it('returns empty for search with no matches', async () => {
230
+ await seedSessions(reader, 1)
231
+ await seedMessages(reader, 'session-1', [
232
+ { role: 'user', content: 'Hello world' },
233
+ ])
234
+
235
+ const results = await reader.searchMessages('xyznonexistent')
236
+ expect(results).toEqual([])
237
+ })
238
+
239
+ it('excludes partial messages from search results', async () => {
240
+ await seedSessions(reader, 1)
241
+ await seedMessages(reader, 'session-1', [
242
+ { role: 'assistant', content: 'partial match here', partial: true },
243
+ { role: 'assistant', content: 'complete match here' },
244
+ ])
245
+
246
+ const results = await reader.searchMessages('match')
247
+ expect(results).toHaveLength(1)
248
+ expect(results[0].content).toBe('complete match here')
249
+ })
250
+ })
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // History Formatting Tests
254
+ // ---------------------------------------------------------------------------
255
+
256
+ describe('formatSessionList', () => {
257
+ let reader: HistoryDbReader
258
+
259
+ beforeEach(async () => {
260
+ reader = await createTestReader()
261
+ })
262
+
263
+ afterEach(async () => {
264
+ await reader.close()
265
+ })
266
+
267
+ it('returns formatted list of sessions', async () => {
268
+ await seedSessions(reader, 3)
269
+ const output = await formatSessionList(reader, {})
270
+ expect(output).toContain('3 session(s)')
271
+ expect(output).toContain('session-1')
272
+ expect(output).toContain('session-2')
273
+ expect(output).toContain('session-3')
274
+ expect(output).toContain('Chat 1')
275
+ expect(output).toContain('Chat 2')
276
+ expect(output).toContain('Chat 3')
277
+ expect(output).toContain('claude-3-opus')
278
+ })
279
+
280
+ it('returns "No sessions found." for empty database', async () => {
281
+ const output = await formatSessionList(reader, {})
282
+ expect(output).toBe('No sessions found.')
283
+ })
284
+
285
+ it('respects limit option', async () => {
286
+ await seedSessions(reader, 5)
287
+ const output = await formatSessionList(reader, { limit: 2 })
288
+ expect(output).toContain('2 session(s)')
289
+ // Should contain the 2 most recent, not the oldest
290
+ expect(output).toContain('session-5')
291
+ expect(output).toContain('session-4')
292
+ expect(output).not.toContain('session-1')
293
+ })
294
+
295
+ it('outputs JSON when json flag is set', async () => {
296
+ await seedSessions(reader, 2)
297
+ const output = await formatSessionList(reader, { json: true })
298
+ const parsed = JSON.parse(output)
299
+ expect(Array.isArray(parsed)).toBe(true)
300
+ expect(parsed).toHaveLength(2)
301
+ expect(parsed[0]).toHaveProperty('id')
302
+ expect(parsed[0]).toHaveProperty('title')
303
+ expect(parsed[0]).toHaveProperty('model')
304
+ })
305
+
306
+ it('outputs empty JSON array for empty database with json flag', async () => {
307
+ const output = await formatSessionList(reader, { json: true })
308
+ const parsed = JSON.parse(output)
309
+ expect(parsed).toEqual([])
310
+ })
311
+ })
312
+
313
+ describe('formatSessionView', () => {
314
+ let reader: HistoryDbReader
315
+
316
+ beforeEach(async () => {
317
+ reader = await createTestReader()
318
+ })
319
+
320
+ afterEach(async () => {
321
+ await reader.close()
322
+ })
323
+
324
+ it('returns formatted messages for a session', async () => {
325
+ await seedSessions(reader, 1)
326
+ await seedMessages(reader, 'session-1', [
327
+ { role: 'user', content: 'Hello there' },
328
+ { role: 'assistant', content: 'Hi! How can I help?' },
329
+ ])
330
+
331
+ const output = await formatSessionView(reader, 'session-1', {})
332
+ expect(output).toContain('Chat 1')
333
+ expect(output).toContain('session-1')
334
+ expect(output).toContain('user')
335
+ expect(output).toContain('Hello there')
336
+ expect(output).toContain('assistant')
337
+ expect(output).toContain('Hi! How can I help?')
338
+ })
339
+
340
+ it('returns error for non-existent session', async () => {
341
+ const output = await formatSessionView(reader, 'nonexistent', {})
342
+ expect(output).toContain('Session not found: nonexistent')
343
+ })
344
+
345
+ it('filters messages by role', async () => {
346
+ await seedSessions(reader, 1)
347
+ await seedMessages(reader, 'session-1', [
348
+ { role: 'user', content: 'Question 1' },
349
+ { role: 'assistant', content: 'Answer 1' },
350
+ { role: 'user', content: 'Question 2' },
351
+ ])
352
+
353
+ const output = await formatSessionView(reader, 'session-1', { role: 'user' })
354
+ expect(output).toContain('Question 1')
355
+ expect(output).toContain('Question 2')
356
+ expect(output).not.toContain('Answer 1')
357
+ })
358
+
359
+ it('excludes partial messages', async () => {
360
+ await seedSessions(reader, 1)
361
+ await seedMessages(reader, 'session-1', [
362
+ { role: 'user', content: 'Hello' },
363
+ { role: 'assistant', content: 'Partial...', partial: true },
364
+ { role: 'assistant', content: 'Complete response' },
365
+ ])
366
+
367
+ const output = await formatSessionView(reader, 'session-1', {})
368
+ expect(output).toContain('Hello')
369
+ expect(output).toContain('Complete response')
370
+ expect(output).not.toContain('Partial...')
371
+ })
372
+
373
+ it('handles session with no messages', async () => {
374
+ await seedSessions(reader, 1)
375
+ const output = await formatSessionView(reader, 'session-1', {})
376
+ expect(output).toContain('No messages')
377
+ })
378
+
379
+ it('outputs JSON when json flag is set', async () => {
380
+ await seedSessions(reader, 1)
381
+ await seedMessages(reader, 'session-1', [
382
+ { role: 'user', content: 'Hello' },
383
+ { role: 'assistant', content: 'Hi' },
384
+ ])
385
+
386
+ const output = await formatSessionView(reader, 'session-1', { json: true })
387
+ const parsed = JSON.parse(output)
388
+ expect(parsed).toHaveProperty('session')
389
+ expect(parsed).toHaveProperty('messages')
390
+ expect(parsed.messages).toHaveLength(2)
391
+ })
392
+
393
+ it('respects limit option', async () => {
394
+ await seedSessions(reader, 1)
395
+ await seedMessages(reader, 'session-1', [
396
+ { role: 'user', content: 'Msg 1' },
397
+ { role: 'assistant', content: 'Msg 2' },
398
+ { role: 'user', content: 'Msg 3' },
399
+ { role: 'assistant', content: 'Msg 4' },
400
+ ])
401
+
402
+ const output = await formatSessionView(reader, 'session-1', { limit: 2 })
403
+ // Should show last 2 messages
404
+ expect(output).toContain('Msg 3')
405
+ expect(output).toContain('Msg 4')
406
+ expect(output).not.toContain('Msg 1')
407
+ })
408
+ })
409
+
410
+ describe('formatSearchResults', () => {
411
+ let reader: HistoryDbReader
412
+
413
+ beforeEach(async () => {
414
+ reader = await createTestReader()
415
+ })
416
+
417
+ afterEach(async () => {
418
+ await reader.close()
419
+ })
420
+
421
+ it('returns formatted search results', async () => {
422
+ await seedSessions(reader, 2)
423
+ await seedMessages(reader, 'session-1', [
424
+ { role: 'user', content: 'Fix the login bug' },
425
+ ])
426
+ await seedMessages(reader, 'session-2', [
427
+ { role: 'user', content: 'The login page needs work' },
428
+ ])
429
+
430
+ const output = await formatSearchResults(reader, 'login', {})
431
+ expect(output).toContain('login')
432
+ expect(output).toContain('2 match')
433
+ expect(output).toContain('Chat 1')
434
+ expect(output).toContain('Chat 2')
435
+ })
436
+
437
+ it('returns no matches message for empty results', async () => {
438
+ await seedSessions(reader, 1)
439
+ await seedMessages(reader, 'session-1', [
440
+ { role: 'user', content: 'Hello' },
441
+ ])
442
+
443
+ const output = await formatSearchResults(reader, 'xyznonexistent', {})
444
+ expect(output).toContain('No matches found for "xyznonexistent"')
445
+ })
446
+
447
+ it('filters by session', async () => {
448
+ await seedSessions(reader, 2)
449
+ await seedMessages(reader, 'session-1', [
450
+ { role: 'user', content: 'login issue' },
451
+ ])
452
+ await seedMessages(reader, 'session-2', [
453
+ { role: 'user', content: 'login page' },
454
+ ])
455
+
456
+ const output = await formatSearchResults(reader, 'login', { sessionId: 'session-1' })
457
+ expect(output).toContain('1 match')
458
+ expect(output).toContain('Chat 1')
459
+ expect(output).not.toContain('Chat 2')
460
+ })
461
+
462
+ it('respects limit option', async () => {
463
+ await seedSessions(reader, 1)
464
+ await seedMessages(reader, 'session-1', [
465
+ { role: 'user', content: 'the first' },
466
+ { role: 'assistant', content: 'the second' },
467
+ { role: 'user', content: 'the third' },
468
+ ])
469
+
470
+ const output = await formatSearchResults(reader, 'the', { limit: 2 })
471
+ expect(output).toContain('2 match')
472
+ })
473
+
474
+ it('outputs JSON when json flag is set', async () => {
475
+ await seedSessions(reader, 1)
476
+ await seedMessages(reader, 'session-1', [
477
+ { role: 'user', content: 'fix the bug' },
478
+ ])
479
+
480
+ const output = await formatSearchResults(reader, 'bug', { json: true })
481
+ const parsed = JSON.parse(output)
482
+ expect(Array.isArray(parsed)).toBe(true)
483
+ expect(parsed).toHaveLength(1)
484
+ expect(parsed[0]).toHaveProperty('sessionId')
485
+ expect(parsed[0]).toHaveProperty('sessionTitle')
486
+ expect(parsed[0]).toHaveProperty('content')
487
+ })
488
+
489
+ it('returns empty JSON array for no matches with json flag', async () => {
490
+ const output = await formatSearchResults(reader, 'nothing', { json: true })
491
+ const parsed = JSON.parse(output)
492
+ expect(parsed).toEqual([])
493
+ })
494
+
495
+ it('handles special characters in search query', async () => {
496
+ await seedSessions(reader, 1)
497
+ await seedMessages(reader, 'session-1', [
498
+ { role: 'user', content: 'What about O\'Brien?' },
499
+ ])
500
+
501
+ // Should not throw an error
502
+ const output = await formatSearchResults(reader, "O'Brien", {})
503
+ expect(output).toContain('1 match')
504
+ })
505
+ })
moav2/src/__tests__/command-palette-utils.test.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import {
3
+ fuzzyMatch,
4
+ fuzzyScore,
5
+ tokenFuzzyMatch,
6
+ tokenFuzzyScore,
7
+ sortByRecency,
8
+ sortFiltered,
9
+ recordUsage,
10
+ getRecencyMap,
11
+ pruneRecencyMap,
12
+ type RecencyMap,
13
+ } from '../ui/components/commandPaletteUtils'
14
+
15
+ // --- fuzzyMatch ---
16
+
17
+ describe('fuzzyMatch', () => {
18
+ it('matches non-contiguous characters in order', () => {
19
+ expect(fuzzyMatch('nse', 'New Session')).toBe(true)
20
+ })
21
+
22
+ it('rejects characters out of order', () => {
23
+ // "sna" — s(Session) n(sessioN) a(?) — no 'a' after 'n', so no match
24
+ expect(fuzzyMatch('sna', 'New Session')).toBe(false)
25
+ // "zz" — no 'z' in text at all
26
+ expect(fuzzyMatch('zz', 'New Session')).toBe(false)
27
+ })
28
+
29
+ it('is case-insensitive', () => {
30
+ expect(fuzzyMatch('AG', 'Agent')).toBe(true)
31
+ expect(fuzzyMatch('ag', 'AGENT')).toBe(true)
32
+ })
33
+
34
+ it('matches single character', () => {
35
+ expect(fuzzyMatch('s', 'Settings')).toBe(true)
36
+ expect(fuzzyMatch('z', 'Settings')).toBe(false)
37
+ })
38
+
39
+ it('empty query matches everything', () => {
40
+ expect(fuzzyMatch('', 'anything')).toBe(true)
41
+ expect(fuzzyMatch('', '')).toBe(true)
42
+ })
43
+
44
+ it('query longer than text never matches', () => {
45
+ expect(fuzzyMatch('longquery', 'ab')).toBe(false)
46
+ })
47
+
48
+ it('exact match works', () => {
49
+ expect(fuzzyMatch('Agent', 'Agent')).toBe(true)
50
+ })
51
+
52
+ it('matches with spaces in text', () => {
53
+ expect(fuzzyMatch('bk', 'Browser: Back')).toBe(true)
54
+ })
55
+ })
56
+
57
+ // --- fuzzyScore ---
58
+
59
+ describe('fuzzyScore', () => {
60
+ it('returns 0 for consecutive match', () => {
61
+ expect(fuzzyScore('set', 'Settings')).toBe(0)
62
+ })
63
+
64
+ it('returns -1 for no match', () => {
65
+ expect(fuzzyScore('xyz', 'Settings')).toBe(-1)
66
+ })
67
+
68
+ it('tighter match scores lower (better)', () => {
69
+ const settingsScore = fuzzyScore('set', 'Settings')
70
+ const selectScore = fuzzyScore('set', 'Select Model')
71
+ // "set" in "Settings" is consecutive (gap=0)
72
+ // "set" in "Select Model" has s-e gap
73
+ expect(settingsScore).toBeLessThan(selectScore)
74
+ })
75
+
76
+ it('empty query returns 0', () => {
77
+ expect(fuzzyScore('', 'anything')).toBe(0)
78
+ })
79
+
80
+ it('scores prefix match as 0', () => {
81
+ expect(fuzzyScore('bro', 'Browser: Back')).toBe(0)
82
+ })
83
+
84
+ it('scores spread-out match higher (worse) than tight match', () => {
85
+ // "ag" in "Agent" = consecutive = 0
86
+ // "ag" in "Agent: Clear Input" also consecutive = 0 (same start)
87
+ const score1 = fuzzyScore('ai', 'Agent: Clear Input')
88
+ const score2 = fuzzyScore('ai', 'AI')
89
+ expect(score2).toBeLessThanOrEqual(score1)
90
+ })
91
+ })
92
+
93
+ // --- sortByRecency ---
94
+
95
+ describe('sortByRecency', () => {
96
+ const items = [
97
+ { id: 'a', label: 'Alpha', category: 'Test' },
98
+ { id: 'b', label: 'Beta', category: 'Test' },
99
+ { id: 'c', label: 'Charlie', category: 'Test' },
100
+ { id: 'd', label: 'Delta', category: 'Test' },
101
+ ]
102
+
103
+ it('sorts recently used items first', () => {
104
+ const recency: RecencyMap = {
105
+ c: 1000,
106
+ a: 500,
107
+ }
108
+ const sorted = sortByRecency(items, recency)
109
+ expect(sorted.map(i => i.id)).toEqual(['c', 'a', 'b', 'd'])
110
+ })
111
+
112
+ it('unused items sort alphabetically after used ones', () => {
113
+ const recency: RecencyMap = { b: 100 }
114
+ const sorted = sortByRecency(items, recency)
115
+ expect(sorted[0].id).toBe('b')
116
+ // remaining: Alpha, Charlie, Delta — alphabetical
117
+ expect(sorted.slice(1).map(i => i.id)).toEqual(['a', 'c', 'd'])
118
+ })
119
+
120
+ it('all unused — pure alphabetical', () => {
121
+ const sorted = sortByRecency(items, {})
122
+ expect(sorted.map(i => i.id)).toEqual(['a', 'b', 'c', 'd'])
123
+ })
124
+
125
+ it('does not mutate the original array', () => {
126
+ const original = [...items]
127
+ sortByRecency(items, { c: 1000 })
128
+ expect(items).toEqual(original)
129
+ })
130
+ })
131
+
132
+ // --- sortFiltered ---
133
+
134
+ describe('sortFiltered', () => {
135
+ const items = [
136
+ { id: 'settings', label: 'Settings', category: 'Settings' },
137
+ { id: 'select-model', label: 'Select Model', category: 'Model' },
138
+ { id: 'session-new', label: 'New Session', category: 'Session' },
139
+ ]
140
+
141
+ it('with query, sorts by fuzzy score first', () => {
142
+ const sorted = sortFiltered(items, 'set', {})
143
+ // "Settings" has score 0 (consecutive), "Select Model" has score > 0
144
+ expect(sorted[0].id).toBe('settings')
145
+ })
146
+
147
+ it('with no query, sorts by recency', () => {
148
+ const recency: RecencyMap = { 'session-new': 2000, 'settings': 1000 }
149
+ const sorted = sortFiltered(items, '', recency)
150
+ expect(sorted[0].id).toBe('session-new')
151
+ expect(sorted[1].id).toBe('settings')
152
+ })
153
+
154
+ it('same score falls through to recency', () => {
155
+ const recency: RecencyMap = { 'select-model': 5000 }
156
+ // Both "Settings" and "Select Model" start with 's'
157
+ const sorted = sortFiltered(items, 's', recency)
158
+ // scores may differ, but if tied, recency breaks it
159
+ const selectIdx = sorted.findIndex(i => i.id === 'select-model')
160
+ const sessionIdx = sorted.findIndex(i => i.id === 'session-new')
161
+ // select-model has recency, session-new doesn't — select-model should be before session-new
162
+ // (unless score difference overrides)
163
+ expect(selectIdx).toBeLessThan(sessionIdx)
164
+ })
165
+
166
+ it('matches across category + label combined', () => {
167
+ const cmds = [
168
+ { id: 'session-delete', label: 'Delete Session', category: 'Session' },
169
+ { id: 'settings', label: 'Settings', category: 'Settings' },
170
+ ]
171
+ const matched = cmds.filter(
172
+ item => tokenFuzzyMatch('sd', item.category + ' ' + item.label)
173
+ )
174
+ expect(matched.length).toBe(1)
175
+ expect(matched[0].id).toBe('session-delete')
176
+ })
177
+ })
178
+
179
+ // --- tokenFuzzyMatch ---
180
+
181
+ describe('tokenFuzzyMatch', () => {
182
+ it('matches tokens in any order', () => {
183
+ // "se d" — "se" matches Session, "d" matches Delete — order reversed in text, still matches
184
+ expect(tokenFuzzyMatch('se d', 'Session Delete Session')).toBe(true)
185
+ })
186
+
187
+ it('matches single token same as fuzzyMatch', () => {
188
+ expect(tokenFuzzyMatch('nse', 'New Session')).toBe(true)
189
+ expect(tokenFuzzyMatch('zzz', 'New Session')).toBe(false)
190
+ })
191
+
192
+ it('requires ALL tokens to match', () => {
193
+ // "se z" — "se" matches but "z" doesn't
194
+ expect(tokenFuzzyMatch('se z', 'Session Delete')).toBe(false)
195
+ })
196
+
197
+ it('empty query matches everything', () => {
198
+ expect(tokenFuzzyMatch('', 'anything')).toBe(true)
199
+ expect(tokenFuzzyMatch(' ', 'anything')).toBe(true)
200
+ })
201
+
202
+ it('each token matches independently against full text', () => {
203
+ // "d se" — both "d" and "se" match in "Delete Session"
204
+ expect(tokenFuzzyMatch('d se', 'Delete Session')).toBe(true)
205
+ })
206
+
207
+ it('matches category prefix + label query', () => {
208
+ // "buf ag" — "buf" matches "Buffer", "ag" matches "Agent"
209
+ expect(tokenFuzzyMatch('buf ag', 'Buffer Agent')).toBe(true)
210
+ // Reversed order still works
211
+ expect(tokenFuzzyMatch('ag buf', 'Buffer Agent')).toBe(true)
212
+ })
213
+ })
214
+
215
+ // --- tokenFuzzyScore ---
216
+
217
+ describe('tokenFuzzyScore', () => {
218
+ it('returns -1 if any token fails', () => {
219
+ expect(tokenFuzzyScore('se z', 'Session Delete')).toBe(-1)
220
+ })
221
+
222
+ it('in-order tokens score better than out-of-order', () => {
223
+ // "buf ag" on "Buffer Agent" — in order
224
+ const inOrder = tokenFuzzyScore('buf ag', 'Buffer Agent')
225
+ // "ag buf" on "Buffer Agent" — out of order
226
+ const outOfOrder = tokenFuzzyScore('ag buf', 'Buffer Agent')
227
+ expect(inOrder).toBeLessThan(outOfOrder)
228
+ })
229
+
230
+ it('tighter matches score better', () => {
231
+ // "set" on "Settings foo" — consecutive, score 0
232
+ const tight = tokenFuzzyScore('set', 'Settings foo')
233
+ // "s t" on "Settings foo" — two tokens, each matches but spread out
234
+ const loose = tokenFuzzyScore('s t', 'Settings foo')
235
+ expect(tight).toBeLessThanOrEqual(loose)
236
+ })
237
+
238
+ it('empty query returns 0', () => {
239
+ expect(tokenFuzzyScore('', 'anything')).toBe(0)
240
+ })
241
+ })
242
+
243
+ // --- recordUsage + getRecencyMap (localStorage) ---
244
+
245
+ describe('recordUsage / getRecencyMap', () => {
246
+ beforeEach(() => {
247
+ localStorage.clear()
248
+ })
249
+
250
+ it('records and retrieves usage', () => {
251
+ recordUsage('test-cmd')
252
+ const map = getRecencyMap()
253
+ expect(map['test-cmd']).toBeGreaterThan(0)
254
+ })
255
+
256
+ it('overwrites previous timestamp on re-use', () => {
257
+ recordUsage('cmd-a')
258
+ const first = getRecencyMap()['cmd-a']
259
+ // Small delay to get different timestamp
260
+ recordUsage('cmd-a')
261
+ const second = getRecencyMap()['cmd-a']
262
+ expect(second).toBeGreaterThanOrEqual(first)
263
+ })
264
+
265
+ it('returns empty map when localStorage is empty', () => {
266
+ expect(getRecencyMap()).toEqual({})
267
+ })
268
+
269
+ it('handles corrupted localStorage gracefully', () => {
270
+ localStorage.setItem('moa:command-recency', 'not-json!!!')
271
+ expect(getRecencyMap()).toEqual({})
272
+ })
273
+ })
274
+
275
+ // --- pruneRecencyMap ---
276
+
277
+ describe('pruneRecencyMap', () => {
278
+ it('keeps map as-is when under limit', () => {
279
+ const map: RecencyMap = { a: 100, b: 200 }
280
+ expect(pruneRecencyMap(map, 200)).toEqual(map)
281
+ })
282
+
283
+ it('prunes oldest entries when over limit', () => {
284
+ const map: RecencyMap = {}
285
+ for (let i = 0; i < 210; i++) {
286
+ map[`cmd-${i}`] = i // timestamps 0-209
287
+ }
288
+ const pruned = pruneRecencyMap(map, 200)
289
+ const ids = Object.keys(pruned)
290
+ expect(ids.length).toBe(200)
291
+ // Oldest (cmd-0 through cmd-9) should be gone
292
+ expect(pruned['cmd-0']).toBeUndefined()
293
+ expect(pruned['cmd-9']).toBeUndefined()
294
+ // Most recent should remain
295
+ expect(pruned['cmd-209']).toBe(209)
296
+ expect(pruned['cmd-10']).toBe(10)
297
+ })
298
+
299
+ it('keeps exactly maxSize entries', () => {
300
+ const map: RecencyMap = { a: 1, b: 2, c: 3, d: 4, e: 5 }
301
+ const pruned = pruneRecencyMap(map, 3)
302
+ expect(Object.keys(pruned).length).toBe(3)
303
+ // Should keep c, d, e (most recent)
304
+ expect(pruned['e']).toBe(5)
305
+ expect(pruned['d']).toBe(4)
306
+ expect(pruned['c']).toBe(3)
307
+ })
308
+ })
moav2/src/__tests__/command-palette.test.tsx ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import CommandPalette from '../ui/components/CommandPalette'
4
+
5
+ // jsdom doesn't implement scrollIntoView
6
+ Element.prototype.scrollIntoView = vi.fn()
7
+
8
+ const noop = () => {}
9
+
10
+ function defaultProps(overrides: Record<string, unknown> = {}) {
11
+ return {
12
+ isOpen: true,
13
+ onClose: noop,
14
+ activeBuffer: 'agent' as const,
15
+ onSwitchBuffer: noop,
16
+ sessions: [
17
+ { id: 's1', title: 'Chat One', model: 'claude', createdAt: 1, updatedAt: 1 },
18
+ { id: 's2', title: 'Chat Two', model: 'claude', createdAt: 2, updatedAt: 2 },
19
+ ],
20
+ activeSessionId: 's1',
21
+ onSwitchSession: noop,
22
+ onCreateSession: noop,
23
+ onOpenSettings: noop,
24
+ onOpenSettingsTab: noop,
25
+ canCreateSession: true,
26
+ onDeleteSession: noop,
27
+ onRenameSession: noop,
28
+ currentModel: 'claude-sonnet',
29
+ availableModels: [
30
+ { value: 'claude-sonnet', label: 'Claude Sonnet', group: 'Anthropic' },
31
+ ],
32
+ onSelectModel: noop,
33
+ onBrowserBack: noop,
34
+ onBrowserForward: noop,
35
+ onBrowserReload: noop,
36
+ onBrowserFocusUrl: noop,
37
+ onAgentClearInput: noop,
38
+ onAgentStopGeneration: noop,
39
+ isStreaming: false,
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ afterEach(() => {
45
+ cleanup()
46
+ })
47
+
48
+ beforeEach(() => {
49
+ localStorage.clear()
50
+ })
51
+
52
+ describe('CommandPalette keyboard navigation', () => {
53
+ it('Ctrl+N moves selection down', () => {
54
+ render(<CommandPalette {...defaultProps()} />)
55
+ const input = screen.getByPlaceholderText(/Type a command/)
56
+
57
+ // First item should be selected initially
58
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
59
+ expect(items[0].classList.contains('selected')).toBe(true)
60
+
61
+ // Press Ctrl+N to move down
62
+ fireEvent.keyDown(input, { key: 'n', ctrlKey: true })
63
+ expect(items[1].classList.contains('selected')).toBe(true)
64
+ })
65
+
66
+ it('Ctrl+P moves selection up', () => {
67
+ render(<CommandPalette {...defaultProps()} />)
68
+ const input = screen.getByPlaceholderText(/Type a command/)
69
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
70
+
71
+ // Move down first, then up
72
+ fireEvent.keyDown(input, { key: 'n', ctrlKey: true })
73
+ expect(items[1].classList.contains('selected')).toBe(true)
74
+
75
+ fireEvent.keyDown(input, { key: 'p', ctrlKey: true })
76
+ expect(items[0].classList.contains('selected')).toBe(true)
77
+ })
78
+
79
+ it('Ctrl+P wraps from top to bottom', () => {
80
+ render(<CommandPalette {...defaultProps()} />)
81
+ const input = screen.getByPlaceholderText(/Type a command/)
82
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
83
+
84
+ // At index 0, Ctrl+P should wrap to last item
85
+ fireEvent.keyDown(input, { key: 'p', ctrlKey: true })
86
+ expect(items[items.length - 1].classList.contains('selected')).toBe(true)
87
+ })
88
+
89
+ it('Ctrl+N wraps from bottom to top', () => {
90
+ render(<CommandPalette {...defaultProps()} />)
91
+ const input = screen.getByPlaceholderText(/Type a command/)
92
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
93
+
94
+ // Navigate to last item first
95
+ fireEvent.keyDown(input, { key: 'p', ctrlKey: true })
96
+ expect(items[items.length - 1].classList.contains('selected')).toBe(true)
97
+
98
+ // Ctrl+N should wrap to first
99
+ fireEvent.keyDown(input, { key: 'n', ctrlKey: true })
100
+ expect(items[0].classList.contains('selected')).toBe(true)
101
+ })
102
+ })
103
+
104
+ describe('CommandPalette checkmark rendering', () => {
105
+ it('shows checkmark for active selectable items', () => {
106
+ // activeBuffer is 'agent', so the Agent buffer item should have a checkmark
107
+ render(<CommandPalette {...defaultProps()} />)
108
+ const checkmarks = document.querySelectorAll('.command-palette-check')
109
+ expect(checkmarks.length).toBeGreaterThan(0)
110
+ // The checkmark character
111
+ expect(checkmarks[0].textContent).toBe('\u2713')
112
+ })
113
+
114
+ it('does not show old "current" badge', () => {
115
+ render(<CommandPalette {...defaultProps()} />)
116
+ const activeBadges = document.querySelectorAll('.command-palette-active')
117
+ expect(activeBadges.length).toBe(0)
118
+ })
119
+
120
+ it('executable items never show checkmark', () => {
121
+ // "New Session" is executable — should have no checkmark even though other items do
122
+ render(<CommandPalette {...defaultProps()} />)
123
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
124
+ const newSessionItem = items.find(b => b.textContent?.includes('New Session'))
125
+ expect(newSessionItem).toBeDefined()
126
+ const check = newSessionItem!.querySelector('.command-palette-check')
127
+ expect(check).toBeNull()
128
+ })
129
+ })
130
+
131
+ describe('CommandPalette fuzzy search', () => {
132
+ it('filters by fuzzy match', () => {
133
+ render(<CommandPalette {...defaultProps()} />)
134
+ const input = screen.getByPlaceholderText(/Type a command/)
135
+
136
+ // Type fuzzy query "nse" — should match "New Session"
137
+ fireEvent.change(input, { target: { value: 'nse' } })
138
+
139
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
140
+ const labels = items.map(b => b.querySelector('.command-palette-label')?.textContent)
141
+ expect(labels.some(l => l === 'New Session')).toBe(true)
142
+ })
143
+
144
+ it('matches tokens in any order across category + label', () => {
145
+ render(<CommandPalette {...defaultProps()} />)
146
+ const input = screen.getByPlaceholderText(/Type a command/)
147
+
148
+ // "se d" — "se" matches Session category, "d" matches Delete
149
+ fireEvent.change(input, { target: { value: 'se d' } })
150
+
151
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
152
+ const labels = items.map(b => b.querySelector('.command-palette-label')?.textContent)
153
+ expect(labels.some(l => l === 'Delete Session')).toBe(true)
154
+ })
155
+
156
+ it('shows empty state for unmatched query', () => {
157
+ render(<CommandPalette {...defaultProps()} />)
158
+ const input = screen.getByPlaceholderText(/Type a command/)
159
+
160
+ fireEvent.change(input, { target: { value: 'zzzzz' } })
161
+
162
+ expect(screen.getByText('No matching commands')).toBeDefined()
163
+ })
164
+ })
165
+
166
+ describe('CommandPalette ArrowDown/ArrowUp navigation', () => {
167
+ it('ArrowDown increments selectedIndex', () => {
168
+ render(<CommandPalette {...defaultProps()} />)
169
+ const input = screen.getByPlaceholderText(/Type a command/)
170
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
171
+
172
+ expect(items[0].classList.contains('selected')).toBe(true)
173
+
174
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
175
+ expect(items[1].classList.contains('selected')).toBe(true)
176
+ })
177
+
178
+ it('ArrowUp decrements selectedIndex', () => {
179
+ render(<CommandPalette {...defaultProps()} />)
180
+ const input = screen.getByPlaceholderText(/Type a command/)
181
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
182
+
183
+ // Move down first
184
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
185
+ expect(items[1].classList.contains('selected')).toBe(true)
186
+
187
+ // Move back up
188
+ fireEvent.keyDown(input, { key: 'ArrowUp' })
189
+ expect(items[0].classList.contains('selected')).toBe(true)
190
+ })
191
+
192
+ it('ArrowUp wraps from top to bottom', () => {
193
+ render(<CommandPalette {...defaultProps()} />)
194
+ const input = screen.getByPlaceholderText(/Type a command/)
195
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
196
+
197
+ // At index 0, ArrowUp should wrap to last item
198
+ fireEvent.keyDown(input, { key: 'ArrowUp' })
199
+ expect(items[items.length - 1].classList.contains('selected')).toBe(true)
200
+ })
201
+
202
+ it('ArrowDown wraps from bottom to top', () => {
203
+ render(<CommandPalette {...defaultProps()} />)
204
+ const input = screen.getByPlaceholderText(/Type a command/)
205
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
206
+
207
+ // Navigate to last item first
208
+ fireEvent.keyDown(input, { key: 'ArrowUp' })
209
+ expect(items[items.length - 1].classList.contains('selected')).toBe(true)
210
+
211
+ // ArrowDown should wrap to first
212
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
213
+ expect(items[0].classList.contains('selected')).toBe(true)
214
+ })
215
+
216
+ it('multiple ArrowDown presses advance sequentially', () => {
217
+ render(<CommandPalette {...defaultProps()} />)
218
+ const input = screen.getByPlaceholderText(/Type a command/)
219
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
220
+
221
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
222
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
223
+ fireEvent.keyDown(input, { key: 'ArrowDown' })
224
+
225
+ // Should be at index 3
226
+ expect(items[3].classList.contains('selected')).toBe(true)
227
+ })
228
+ })
229
+
230
+ describe('CommandPalette bang prefix filtering', () => {
231
+ it('!a filters to agent/session commands only', () => {
232
+ render(<CommandPalette {...defaultProps()} />)
233
+ const input = screen.getByPlaceholderText(/Type a command/)
234
+
235
+ fireEvent.change(input, { target: { value: '!a' } })
236
+
237
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
238
+ const categories = items.map(b => b.querySelector('.command-palette-category')?.textContent?.toLowerCase())
239
+
240
+ // All visible items should be agent or session
241
+ for (const cat of categories) {
242
+ expect(cat === 'session' || cat === 'agent').toBe(true)
243
+ }
244
+ // Should have at least some items
245
+ expect(items.length).toBeGreaterThan(0)
246
+ })
247
+
248
+ it('!t filters to terminal commands only', () => {
249
+ render(<CommandPalette {...defaultProps({
250
+ terminalTabs: [{ id: 't1', title: 'Thread 1' }],
251
+ activeTerminalTabId: 't1',
252
+ onSwitchTerminalTab: noop,
253
+ onCreateTerminalTab: noop,
254
+ })} />)
255
+ const input = screen.getByPlaceholderText(/Type a command/)
256
+
257
+ fireEvent.change(input, { target: { value: '!t' } })
258
+
259
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
260
+ const categories = items.map(b => b.querySelector('.command-palette-category')?.textContent?.toLowerCase())
261
+
262
+ for (const cat of categories) {
263
+ expect(cat).toBe('terminal')
264
+ }
265
+ expect(items.length).toBeGreaterThan(0)
266
+ })
267
+
268
+ it('!b filters to browser commands only', () => {
269
+ render(<CommandPalette {...defaultProps({
270
+ browserTabs: [{ id: 'b1', title: 'Browser Tab 1' }],
271
+ activeBrowserTabId: 'b1',
272
+ onSwitchBrowserTab: noop,
273
+ onCreateBrowserTab: noop,
274
+ })} />)
275
+ const input = screen.getByPlaceholderText(/Type a command/)
276
+
277
+ fireEvent.change(input, { target: { value: '!b' } })
278
+
279
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
280
+ const categories = items.map(b => b.querySelector('.command-palette-category')?.textContent?.toLowerCase())
281
+
282
+ for (const cat of categories) {
283
+ expect(cat === 'browser' || cat === 'browser tab').toBe(true)
284
+ }
285
+ expect(items.length).toBeGreaterThan(0)
286
+ })
287
+
288
+ it('!a with search further filters within agent commands', () => {
289
+ render(<CommandPalette {...defaultProps()} />)
290
+ const input = screen.getByPlaceholderText(/Type a command/)
291
+
292
+ // "!a new" — agent filter + fuzzy "new"
293
+ fireEvent.change(input, { target: { value: '!a new' } })
294
+
295
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
296
+ const labels = items.map(b => b.querySelector('.command-palette-label')?.textContent)
297
+
298
+ // Should match "New Session" within agent/session category
299
+ expect(labels.some(l => l === 'New Session')).toBe(true)
300
+
301
+ // Should NOT show browser/terminal/settings commands
302
+ const categories = items.map(b => b.querySelector('.command-palette-category')?.textContent?.toLowerCase())
303
+ for (const cat of categories) {
304
+ expect(cat === 'session' || cat === 'agent').toBe(true)
305
+ }
306
+ })
307
+
308
+ it('shows correct placeholder for bang prefix', () => {
309
+ render(<CommandPalette {...defaultProps()} />)
310
+ const input = screen.getByPlaceholderText(/Type a command/)
311
+
312
+ fireEvent.change(input, { target: { value: '!b' } })
313
+
314
+ // After re-render, the placeholder should update to "Search browser tabs..."
315
+ // We verify by checking the input still has the bang value
316
+ expect((input as HTMLInputElement).value).toBe('!b')
317
+ })
318
+
319
+ it('bang prefix with no matching items shows empty state', () => {
320
+ render(<CommandPalette {...defaultProps()} />)
321
+ const input = screen.getByPlaceholderText(/Type a command/)
322
+
323
+ fireEvent.change(input, { target: { value: '!t zzzzz' } })
324
+
325
+ expect(screen.getByText('No matching commands')).toBeDefined()
326
+ })
327
+ })
328
+
329
+ describe('CommandPalette recency tracking', () => {
330
+ it('records usage when a command is executed via Enter', () => {
331
+ // Use a spy for onClose so the palette doesn't unmount
332
+ const onClose = vi.fn()
333
+ render(<CommandPalette {...defaultProps({ onClose })} />)
334
+ const input = screen.getByPlaceholderText(/Type a command/)
335
+
336
+ // Press Enter on the first item
337
+ fireEvent.keyDown(input, { key: 'Enter' })
338
+
339
+ // Check localStorage was updated
340
+ const raw = localStorage.getItem('moa:command-recency')
341
+ expect(raw).not.toBeNull()
342
+ const map = JSON.parse(raw!)
343
+ expect(Object.keys(map).length).toBeGreaterThan(0)
344
+ })
345
+
346
+ it('records usage when a command is clicked', () => {
347
+ render(<CommandPalette {...defaultProps()} />)
348
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
349
+
350
+ // Click the second item
351
+ fireEvent.click(items[1])
352
+
353
+ const raw = localStorage.getItem('moa:command-recency')
354
+ expect(raw).not.toBeNull()
355
+ const map = JSON.parse(raw!)
356
+ expect(Object.keys(map).length).toBeGreaterThan(0)
357
+ })
358
+ })
359
+
360
+ describe('CommandPalette Escape behavior', () => {
361
+ it('Escape closes the palette in command mode', () => {
362
+ const onClose = vi.fn()
363
+ render(<CommandPalette {...defaultProps({ onClose })} />)
364
+ const input = screen.getByPlaceholderText(/Type a command/)
365
+
366
+ fireEvent.keyDown(input, { key: 'Escape' })
367
+ expect(onClose).toHaveBeenCalledOnce()
368
+ })
369
+
370
+ it('mouseEnter updates selected index', () => {
371
+ render(<CommandPalette {...defaultProps()} />)
372
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
373
+
374
+ // Hover over 3rd item
375
+ fireEvent.mouseEnter(items[2])
376
+ expect(items[2].classList.contains('selected')).toBe(true)
377
+ // Previous should no longer be selected
378
+ expect(items[0].classList.contains('selected')).toBe(false)
379
+ })
380
+ })
381
+
382
+ describe('CommandPalette initial mode behavior', () => {
383
+ it('opens directly in model list when initialMode is models', () => {
384
+ render(<CommandPalette {...defaultProps({ initialMode: 'models' })} />)
385
+ expect(screen.getByPlaceholderText(/Select a model/)).toBeDefined()
386
+ })
387
+
388
+ it('Escape closes when opened directly in model mode', () => {
389
+ const onClose = vi.fn()
390
+ render(<CommandPalette {...defaultProps({ initialMode: 'models', onClose })} />)
391
+ const input = screen.getByPlaceholderText(/Select a model/)
392
+
393
+ fireEvent.keyDown(input, { key: 'Escape' })
394
+ expect(onClose).toHaveBeenCalledOnce()
395
+ })
396
+
397
+ it('Backspace closes when opened directly in model mode', () => {
398
+ const onClose = vi.fn()
399
+ render(<CommandPalette {...defaultProps({ initialMode: 'models', onClose })} />)
400
+ const input = screen.getByPlaceholderText(/Select a model/)
401
+
402
+ fireEvent.keyDown(input, { key: 'Backspace' })
403
+ expect(onClose).toHaveBeenCalledOnce()
404
+ })
405
+ })
406
+
407
+ describe('CommandPalette open/close events', () => {
408
+ it('dispatches open and close lifecycle events', () => {
409
+ const onOpen = vi.fn()
410
+ const onClose = vi.fn()
411
+ window.addEventListener('moa:command-palette-open', onOpen)
412
+ window.addEventListener('moa:command-palette-close', onClose)
413
+
414
+ const { rerender, unmount } = render(<CommandPalette {...defaultProps({ isOpen: true })} />)
415
+ expect(onOpen).toHaveBeenCalledOnce()
416
+
417
+ rerender(<CommandPalette {...defaultProps({ isOpen: false })} />)
418
+ expect(onClose).toHaveBeenCalledOnce()
419
+
420
+ window.removeEventListener('moa:command-palette-open', onOpen)
421
+ window.removeEventListener('moa:command-palette-close', onClose)
422
+ unmount()
423
+ })
424
+ })
425
+
426
+ describe('CommandPalette session switching', () => {
427
+ it('shows all sessions as selectable items', () => {
428
+ render(<CommandPalette {...defaultProps()} />)
429
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
430
+ const labels = items.map(b => b.querySelector('.command-palette-label')?.textContent)
431
+
432
+ expect(labels).toContain('Chat One')
433
+ expect(labels).toContain('Chat Two')
434
+ })
435
+
436
+ it('marks active session with checkmark', () => {
437
+ render(<CommandPalette {...defaultProps()} />)
438
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
439
+ const chatOneItem = items.find(b => b.querySelector('.command-palette-label')?.textContent === 'Chat One')
440
+ expect(chatOneItem).toBeDefined()
441
+ // Chat One is activeSessionId='s1', so it should have a checkmark
442
+ const check = chatOneItem!.querySelector('.command-palette-check')
443
+ expect(check).not.toBeNull()
444
+ })
445
+
446
+ it('calls onSwitchSession when a session is selected via Enter', () => {
447
+ const onSwitchSession = vi.fn()
448
+ const onClose = vi.fn()
449
+ render(<CommandPalette {...defaultProps({ onSwitchSession, onClose })} />)
450
+ const input = screen.getByPlaceholderText(/Type a command/)
451
+
452
+ // Search for "Chat Two" to filter to that session
453
+ fireEvent.change(input, { target: { value: 'Chat Two' } })
454
+
455
+ const items = screen.getAllByRole('button').filter(b => b.classList.contains('command-palette-item'))
456
+ const chatTwoItem = items.find(b => b.querySelector('.command-palette-label')?.textContent === 'Chat Two')
457
+ expect(chatTwoItem).toBeDefined()
458
+
459
+ // Click it
460
+ fireEvent.click(chatTwoItem!)
461
+ expect(onSwitchSession).toHaveBeenCalledWith('s2')
462
+ })
463
+ })
moav2/src/__tests__/dropdown.test.tsx ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3
+ import Dropdown from '../ui/components/Dropdown'
4
+
5
+ afterEach(() => {
6
+ cleanup()
7
+ })
8
+
9
+ describe('Dropdown', () => {
10
+ it('opens and selects with Enter', () => {
11
+ const onChange = vi.fn()
12
+ render(
13
+ <Dropdown
14
+ options={[
15
+ { value: 'a', label: 'Alpha' },
16
+ { value: 'b', label: 'Beta' },
17
+ ]}
18
+ value="a"
19
+ onChange={onChange}
20
+ />
21
+ )
22
+
23
+ const trigger = screen.getByRole('combobox')
24
+ fireEvent.click(trigger)
25
+ expect(screen.getByRole('listbox')).toBeTruthy()
26
+
27
+ fireEvent.keyDown(trigger, { key: 'ArrowDown' })
28
+ fireEvent.keyDown(trigger, { key: 'Enter' })
29
+ expect(onChange).toHaveBeenCalledWith('b')
30
+ })
31
+
32
+ it('supports Ctrl+N and Ctrl+P navigation', () => {
33
+ render(
34
+ <Dropdown
35
+ options={[
36
+ { value: 'a', label: 'Alpha' },
37
+ { value: 'b', label: 'Beta' },
38
+ { value: 'c', label: 'Gamma' },
39
+ ]}
40
+ value="a"
41
+ onChange={() => {}}
42
+ />
43
+ )
44
+
45
+ const trigger = screen.getByRole('combobox')
46
+ fireEvent.click(trigger)
47
+
48
+ fireEvent.keyDown(trigger, { key: 'n', ctrlKey: true })
49
+ expect(screen.getByRole('option', { name: 'Beta' }).className).toContain('highlighted')
50
+
51
+ fireEvent.keyDown(trigger, { key: 'p', ctrlKey: true })
52
+ expect(screen.getByRole('option', { name: /Alpha/ }).className).toContain('highlighted')
53
+ })
54
+
55
+ it('closes on Escape', () => {
56
+ render(
57
+ <Dropdown
58
+ options={[{ value: 'a', label: 'Alpha' }]}
59
+ value="a"
60
+ onChange={() => {}}
61
+ />
62
+ )
63
+
64
+ const trigger = screen.getByRole('combobox')
65
+ fireEvent.click(trigger)
66
+ expect(screen.getByRole('listbox')).toBeTruthy()
67
+ fireEvent.keyDown(trigger, { key: 'Escape' })
68
+ expect(screen.queryByRole('listbox')).toBeNull()
69
+ })
70
+
71
+ it('applies aria-selected to the selected value', () => {
72
+ render(
73
+ <Dropdown
74
+ options={[
75
+ { value: 'a', label: 'Alpha' },
76
+ { value: 'b', label: 'Beta' },
77
+ ]}
78
+ value="b"
79
+ onChange={() => {}}
80
+ />
81
+ )
82
+
83
+ fireEvent.click(screen.getByRole('combobox'))
84
+ expect(screen.getByRole('option', { name: 'Alpha' }).getAttribute('aria-selected')).toBe('false')
85
+ expect(screen.getByRole('option', { name: /Beta/ }).getAttribute('aria-selected')).toBe('true')
86
+ })
87
+
88
+ it('closes on outside click and returns focus to trigger', () => {
89
+ render(
90
+ <>
91
+ <button type="button">Outside</button>
92
+ <Dropdown
93
+ options={[{ value: 'a', label: 'Alpha' }]}
94
+ value="a"
95
+ onChange={() => {}}
96
+ />
97
+ </>
98
+ )
99
+
100
+ const trigger = screen.getByRole('combobox')
101
+ fireEvent.click(trigger)
102
+ expect(screen.getByRole('listbox')).toBeTruthy()
103
+
104
+ fireEvent.mouseDown(screen.getByRole('button', { name: 'Outside' }))
105
+ expect(screen.queryByRole('listbox')).toBeNull()
106
+ expect(document.activeElement).toBe(trigger)
107
+ })
108
+ })
moav2/src/__tests__/event-store.test.ts ADDED
@@ -0,0 +1,483 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * EventStore tests.
3
+ *
4
+ * wa-sqlite uses WASM which does not load in jsdom/vitest, so we test EventStore
5
+ * by providing a mock PlatformDatabase that implements the same async interface.
6
+ * This verifies EventStore's logic (append, query, search, materialize, count,
7
+ * replay) without requiring an actual WASM SQLite binary.
8
+ */
9
+ import { describe, it, expect, beforeEach } from 'vitest'
10
+ import 'fake-indexeddb/auto'
11
+ import { setPlatform } from '../core/platform'
12
+ import type { Platform, PlatformDatabase, PlatformStatement, PlatformSqlite } from '../core/platform/types'
13
+ import { EventStore } from '../core/services/event-store'
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // In-memory SQL-ish database mock that mimics PlatformDatabase
17
+ // ---------------------------------------------------------------------------
18
+
19
+ interface Row {
20
+ rowid: number
21
+ id: string
22
+ type: string
23
+ payload: string
24
+ actor: string
25
+ session_id: string | null
26
+ causation_id: string | null
27
+ correlation_id: string | null
28
+ timestamp: number
29
+ }
30
+
31
+ class InMemoryDB implements PlatformDatabase {
32
+ private rows: Row[] = []
33
+ private nextRowId = 1
34
+
35
+ async exec(_sql: string): Promise<void> {
36
+ // Schema creation — no-op for in-memory store
37
+ }
38
+
39
+ prepare(sql: string): PlatformStatement {
40
+ const self = this
41
+ const trimmed = sql.trim()
42
+
43
+ return {
44
+ async run(...params: any[]): Promise<any> {
45
+ if (trimmed.toUpperCase().startsWith('INSERT')) {
46
+ const row: Row = {
47
+ rowid: self.nextRowId++,
48
+ id: params[0],
49
+ type: params[1],
50
+ payload: params[2],
51
+ actor: params[3],
52
+ session_id: params[4],
53
+ causation_id: params[5],
54
+ correlation_id: params[6],
55
+ timestamp: params[7],
56
+ }
57
+ self.rows.push(row)
58
+ return { changes: 1 }
59
+ }
60
+ return { changes: 0 }
61
+ },
62
+
63
+ async get(...params: any[]): Promise<any> {
64
+ const rows = await this.all(...params)
65
+ return rows.length > 0 ? rows[0] : undefined
66
+ },
67
+
68
+ async all(...params: any[]): Promise<any[]> {
69
+ return self.executeQuery(trimmed, params)
70
+ },
71
+ }
72
+ }
73
+
74
+ async close(): Promise<void> {}
75
+
76
+ /**
77
+ * Simple query engine supporting the SQL patterns EventStore generates.
78
+ */
79
+ private executeQuery(sql: string, params: any[]): any[] {
80
+ const upper = sql.toUpperCase()
81
+
82
+ // SELECT COUNT(*) as cnt FROM events ...
83
+ if (upper.includes('SELECT COUNT(*)')) {
84
+ const filtered = this.applyFilters(upper, params)
85
+ return [{ cnt: filtered.length }]
86
+ }
87
+
88
+ // SELECT DISTINCT type FROM events ORDER BY type
89
+ if (upper.includes('SELECT DISTINCT TYPE FROM')) {
90
+ const types = [...new Set(this.rows.map(r => r.type))].sort()
91
+ return types.map(t => ({ type: t }))
92
+ }
93
+
94
+ // SELECT DISTINCT session_id FROM events WHERE session_id IS NOT NULL
95
+ if (upper.includes('SELECT DISTINCT SESSION_ID FROM')) {
96
+ const sessions = [...new Set(
97
+ this.rows.filter(r => r.session_id != null).map(r => r.session_id)
98
+ )].sort()
99
+ return sessions.map(s => ({ session_id: s }))
100
+ }
101
+
102
+ // SELECT * FROM events WHERE id = ?
103
+ if (upper.includes('WHERE ID = ?')) {
104
+ const row = this.rows.find(r => r.id === params[0])
105
+ return row ? [row] : []
106
+ }
107
+
108
+ // FTS search: SELECT e.* FROM events e INNER JOIN events_fts ...
109
+ if (upper.includes('EVENTS_FTS MATCH')) {
110
+ return this.executeFtsSearch(upper, params)
111
+ }
112
+
113
+ // Generic SELECT with WHERE / ORDER BY / LIMIT / OFFSET
114
+ return this.executeGenericSelect(upper, params)
115
+ }
116
+
117
+ private executeFtsSearch(upper: string, params: any[]): any[] {
118
+ const query = (params[0] as string).toLowerCase()
119
+ let filtered = this.rows.filter(r =>
120
+ r.type.toLowerCase().includes(query) ||
121
+ r.payload.toLowerCase().includes(query)
122
+ )
123
+
124
+ if (upper.includes('E.SESSION_ID = ?')) {
125
+ filtered = filtered.filter(r => r.session_id === params[1])
126
+ const limit = params[2] ?? 50
127
+ return filtered.slice(0, limit)
128
+ }
129
+
130
+ const limit = params[1] ?? 50
131
+ return filtered.slice(0, limit)
132
+ }
133
+
134
+ private executeGenericSelect(upper: string, params: any[]): any[] {
135
+ let filtered = this.applyFilters(upper, params)
136
+ let paramIdx = this.countWhereParams(upper)
137
+
138
+ // ORDER BY
139
+ if (upper.includes('ORDER BY TIMESTAMP ASC')) {
140
+ filtered.sort((a, b) => a.timestamp - b.timestamp || a.id.localeCompare(b.id))
141
+ } else if (upper.includes('ORDER BY TIMESTAMP DESC')) {
142
+ filtered.sort((a, b) => b.timestamp - a.timestamp || b.id.localeCompare(a.id))
143
+ }
144
+
145
+ // LIMIT ? OFFSET ?
146
+ if (upper.includes('LIMIT ?')) {
147
+ const limit = params[paramIdx++]
148
+ const offset = upper.includes('OFFSET ?') ? params[paramIdx++] : 0
149
+ filtered = filtered.slice(offset, offset + limit)
150
+ } else {
151
+ // LIMIT with literal number (e.g., LIMIT 1 in getLatest)
152
+ const limitMatch = upper.match(/LIMIT\s+(\d+)/)
153
+ if (limitMatch) {
154
+ filtered = filtered.slice(0, parseInt(limitMatch[1]))
155
+ }
156
+ }
157
+
158
+ return filtered
159
+ }
160
+
161
+ /**
162
+ * Apply WHERE clause filters. Returns filtered rows.
163
+ * Params are consumed in the order EventStore generates them:
164
+ * type, session_id, actor, timestamp>=, timestamp<=
165
+ */
166
+ private applyFilters(upper: string, params: any[]): Row[] {
167
+ let filtered = [...this.rows]
168
+ if (!upper.includes('WHERE')) return filtered
169
+
170
+ let paramIdx = 0
171
+
172
+ // type LIKE ? or type = ?
173
+ if (upper.includes('TYPE LIKE ?')) {
174
+ const pattern = params[paramIdx++] as string
175
+ const regex = new RegExp('^' + pattern.replace(/%/g, '.*') + '$')
176
+ filtered = filtered.filter(r => regex.test(r.type))
177
+ } else if (upper.includes('TYPE = ?')) {
178
+ const typeVal = params[paramIdx++]
179
+ filtered = filtered.filter(r => r.type === typeVal)
180
+ }
181
+
182
+ // session_id = ?
183
+ if (upper.includes('SESSION_ID = ?')) {
184
+ const sidVal = params[paramIdx++]
185
+ filtered = filtered.filter(r => r.session_id === sidVal)
186
+ }
187
+
188
+ // actor = ?
189
+ if (upper.includes('ACTOR = ?')) {
190
+ const actorVal = params[paramIdx++]
191
+ filtered = filtered.filter(r => r.actor === actorVal)
192
+ }
193
+
194
+ // timestamp >= ?
195
+ if (upper.includes('TIMESTAMP >= ?')) {
196
+ const since = params[paramIdx++]
197
+ filtered = filtered.filter(r => r.timestamp >= since)
198
+ }
199
+
200
+ // timestamp <= ?
201
+ if (upper.includes('TIMESTAMP <= ?')) {
202
+ const until = params[paramIdx++]
203
+ filtered = filtered.filter(r => r.timestamp <= until)
204
+ }
205
+
206
+ return filtered
207
+ }
208
+
209
+ private countWhereParams(upper: string): number {
210
+ if (!upper.includes('WHERE')) return 0
211
+ let count = 0
212
+ if (upper.includes('TYPE LIKE ?') || upper.includes('TYPE = ?')) count++
213
+ if (upper.includes('SESSION_ID = ?')) count++
214
+ if (upper.includes('ACTOR = ?')) count++
215
+ if (upper.includes('TIMESTAMP >= ?')) count++
216
+ if (upper.includes('TIMESTAMP <= ?')) count++
217
+ return count
218
+ }
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Mock platform with in-memory SQLite replacement
223
+ // ---------------------------------------------------------------------------
224
+
225
+ function createMockPlatform(): Platform {
226
+ const dbInstances = new Map<string, InMemoryDB>()
227
+
228
+ const mockSqlite: PlatformSqlite = {
229
+ async open(name: string): Promise<PlatformDatabase> {
230
+ if (!dbInstances.has(name)) {
231
+ dbInstances.set(name, new InMemoryDB())
232
+ }
233
+ return dbInstances.get(name)!
234
+ }
235
+ }
236
+
237
+ return {
238
+ fs: {
239
+ readFile: async () => '',
240
+ readFileSync: () => '',
241
+ writeFile: async () => {},
242
+ writeFileSync: () => {},
243
+ existsSync: () => false,
244
+ mkdirSync: () => {},
245
+ readdirSync: () => [],
246
+ statSync: () => ({ isFile: () => false, isDirectory: () => false, size: 0 }),
247
+ unlinkSync: () => {},
248
+ },
249
+ path: {
250
+ join: (...parts: string[]) => parts.join('/').replace(/\/+/g, '/'),
251
+ dirname: (p: string) => p.split('/').slice(0, -1).join('/') || '/',
252
+ resolve: (...parts: string[]) => parts.join('/'),
253
+ basename: (p: string) => p.split('/').pop() || '',
254
+ extname: (p: string) => { const m = p.match(/\.[^.]+$/); return m ? m[0] : '' },
255
+ sep: '/',
256
+ },
257
+ process: {
258
+ exec: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
259
+ execSync: () => '',
260
+ cwd: () => '/',
261
+ env: {},
262
+ homedir: () => '/',
263
+ },
264
+ sqlite: mockSqlite,
265
+ shell: { openExternal: () => {} },
266
+ type: 'browser',
267
+ }
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Tests
272
+ // ---------------------------------------------------------------------------
273
+
274
+ describe('EventStore (with in-memory mock DB)', () => {
275
+ let store: EventStore
276
+
277
+ beforeEach(async () => {
278
+ const platform = createMockPlatform()
279
+ setPlatform(platform)
280
+ store = await EventStore.create()
281
+ })
282
+
283
+ it('appends and retrieves an event', async () => {
284
+ const evt = await store.append({
285
+ type: 'message.sent',
286
+ payload: { text: 'hello world' },
287
+ actor: 'user',
288
+ sessionId: 'test-session',
289
+ })
290
+
291
+ expect(evt.id).toBeTruthy()
292
+ expect(evt.timestamp).toBeGreaterThan(0)
293
+ expect(evt.type).toBe('message.sent')
294
+ expect(evt.payload.text).toBe('hello world')
295
+
296
+ const retrieved = await store.getById(evt.id)
297
+ expect(retrieved).toBeDefined()
298
+ expect(retrieved!.payload.text).toBe('hello world')
299
+ expect(retrieved!.actor).toBe('user')
300
+ expect(retrieved!.sessionId).toBe('test-session')
301
+ })
302
+
303
+ it('queries by type with glob pattern', async () => {
304
+ await store.append({ type: 'message.sent', payload: { text: 'a' }, actor: 'user' })
305
+ await store.append({ type: 'file.read', payload: { path: '/x' }, actor: 'agent' })
306
+ await store.append({ type: 'message.received', payload: { text: 'b' }, actor: 'agent' })
307
+
308
+ const messages = await store.query({ type: 'message.*' })
309
+ expect(messages.length).toBeGreaterThanOrEqual(2)
310
+ expect(messages.every(m => m.type.startsWith('message.'))).toBe(true)
311
+ })
312
+
313
+ it('queries by exact type', async () => {
314
+ await store.append({ type: 'message.sent', payload: { text: 'a' }, actor: 'user' })
315
+ await store.append({ type: 'file.read', payload: { path: '/x' }, actor: 'agent' })
316
+
317
+ const fileEvents = await store.query({ type: 'file.read' })
318
+ expect(fileEvents.length).toBe(1)
319
+ expect(fileEvents[0].type).toBe('file.read')
320
+ })
321
+
322
+ it('queries by session', async () => {
323
+ await store.append({ type: 'test.event', payload: {}, actor: 'user', sessionId: 'session-a' })
324
+ await store.append({ type: 'test.event', payload: {}, actor: 'user', sessionId: 'session-b' })
325
+ await store.append({ type: 'test.event', payload: {}, actor: 'user', sessionId: 'session-a' })
326
+
327
+ const result = await store.query({ sessionId: 'session-a' })
328
+ expect(result.length).toBe(2)
329
+ expect(result.every(e => e.sessionId === 'session-a')).toBe(true)
330
+ })
331
+
332
+ it('queries by actor', async () => {
333
+ await store.append({ type: 'x', payload: {}, actor: 'user' })
334
+ await store.append({ type: 'x', payload: {}, actor: 'agent' })
335
+ await store.append({ type: 'x', payload: {}, actor: 'user' })
336
+
337
+ const result = await store.query({ actor: 'user' })
338
+ expect(result.length).toBe(2)
339
+ expect(result.every(e => e.actor === 'user')).toBe(true)
340
+ })
341
+
342
+ it('full-text search works', async () => {
343
+ await store.append({ type: 'note', payload: { text: 'quantum computing is fascinating' }, actor: 'user' })
344
+ await store.append({ type: 'note', payload: { text: 'classical computing' }, actor: 'user' })
345
+
346
+ const results = await store.search('quantum')
347
+ expect(results.length).toBeGreaterThanOrEqual(1)
348
+ expect(results.some(r => r.payload.text.includes('quantum'))).toBe(true)
349
+ })
350
+
351
+ it('search returns empty for empty query', async () => {
352
+ await store.append({ type: 'note', payload: { text: 'something' }, actor: 'user' })
353
+ const results = await store.search(' ')
354
+ expect(results.length).toBe(0)
355
+ })
356
+
357
+ it('materializes a session', async () => {
358
+ const sid = 'mat-session'
359
+ await store.append({ type: 'message.sent', payload: { text: 'hi' }, actor: 'user', sessionId: sid })
360
+ await store.append({ type: 'file.read', payload: { path: '/x' }, actor: 'agent', sessionId: sid })
361
+ await store.append({ type: 'intent.created', payload: { goal: 'test' }, actor: 'user', sessionId: sid })
362
+ await store.append({ type: 'command.executed', payload: { cmd: 'ls' }, actor: 'agent', sessionId: sid })
363
+ await store.append({ type: 'session.created', payload: {}, actor: 'system', sessionId: sid })
364
+ await store.append({ type: 'unknown.event', payload: {}, actor: 'system', sessionId: sid })
365
+
366
+ const mat = await store.materialize(sid)
367
+ expect(mat.messages.length).toBeGreaterThanOrEqual(1)
368
+ expect(mat.fileOps.length).toBeGreaterThanOrEqual(1)
369
+ expect(mat.intents.length).toBeGreaterThanOrEqual(1)
370
+ expect(mat.commands.length).toBeGreaterThanOrEqual(1)
371
+ expect(mat.sessions.length).toBeGreaterThanOrEqual(1)
372
+ expect(mat.other.length).toBeGreaterThanOrEqual(1)
373
+ })
374
+
375
+ it('materialize returns empty categories for non-existent session', async () => {
376
+ const mat = await store.materialize('nonexistent')
377
+ expect(mat.messages.length).toBe(0)
378
+ expect(mat.fileOps.length).toBe(0)
379
+ expect(mat.intents.length).toBe(0)
380
+ })
381
+
382
+ it('counts events (no filter)', async () => {
383
+ const initial = await store.count()
384
+ await store.append({ type: 'count.test', payload: {}, actor: 'system' })
385
+ expect(await store.count()).toBe(initial + 1)
386
+ })
387
+
388
+ it('counts events with type filter', async () => {
389
+ await store.append({ type: 'alpha', payload: {}, actor: 'user' })
390
+ await store.append({ type: 'beta', payload: {}, actor: 'user' })
391
+ await store.append({ type: 'alpha', payload: {}, actor: 'user' })
392
+
393
+ expect(await store.count('alpha')).toBe(2)
394
+ expect(await store.count('beta')).toBe(1)
395
+ })
396
+
397
+ it('counts events with session filter', async () => {
398
+ await store.append({ type: 'x', payload: {}, actor: 'user', sessionId: 'count-s1' })
399
+ await store.append({ type: 'x', payload: {}, actor: 'user', sessionId: 'count-s2' })
400
+ await store.append({ type: 'x', payload: {}, actor: 'user', sessionId: 'count-s1' })
401
+
402
+ expect(await store.count(undefined, 'count-s1')).toBe(2)
403
+ expect(await store.count(undefined, 'count-s2')).toBe(1)
404
+ })
405
+
406
+ it('replays with a reducer', async () => {
407
+ const sid = 'replay-session'
408
+ await store.append({ type: 'counter.inc', payload: { n: 1 }, actor: 'user', sessionId: sid })
409
+ await store.append({ type: 'counter.inc', payload: { n: 2 }, actor: 'user', sessionId: sid })
410
+ await store.append({ type: 'counter.inc', payload: { n: 3 }, actor: 'user', sessionId: sid })
411
+
412
+ const total = await store.replay(
413
+ (sum, evt) => sum + (evt.payload.n || 0),
414
+ 0,
415
+ { sessionId: sid, type: 'counter.inc' }
416
+ )
417
+ expect(total).toBe(6)
418
+ })
419
+
420
+ it('replay with no matching events returns initial state', async () => {
421
+ const result = await store.replay(
422
+ (sum, _evt) => sum + 1,
423
+ 42,
424
+ { sessionId: 'nonexistent', type: 'nope' }
425
+ )
426
+ expect(result).toBe(42)
427
+ })
428
+
429
+ it('appendBatch inserts multiple events', async () => {
430
+ const events = await store.appendBatch([
431
+ { type: 'batch.a', payload: { i: 1 }, actor: 'user' },
432
+ { type: 'batch.b', payload: { i: 2 }, actor: 'user' },
433
+ { type: 'batch.c', payload: { i: 3 }, actor: 'user' },
434
+ ])
435
+ expect(events.length).toBe(3)
436
+ expect(events[0].type).toBe('batch.a')
437
+ expect(events[2].type).toBe('batch.c')
438
+
439
+ expect(await store.count('batch.*')).toBe(3)
440
+ })
441
+
442
+ it('getLatest returns the most recent event of a type', async () => {
443
+ await store.append({ type: 'latest.test', payload: { v: 1 }, actor: 'user' })
444
+ // Small delay to ensure distinct timestamps
445
+ await new Promise(r => setTimeout(r, 5))
446
+ await store.append({ type: 'latest.test', payload: { v: 2 }, actor: 'user' })
447
+
448
+ const latest = await store.getLatest('latest.test')
449
+ expect(latest).toBeDefined()
450
+ expect(latest!.payload.v).toBe(2)
451
+ })
452
+
453
+ it('getLatest returns null when no events match', async () => {
454
+ const latest = await store.getLatest('nonexistent.type')
455
+ expect(latest).toBeNull()
456
+ })
457
+
458
+ it('types() returns all distinct event types', async () => {
459
+ await store.append({ type: 'alpha', payload: {}, actor: 'user' })
460
+ await store.append({ type: 'beta', payload: {}, actor: 'user' })
461
+ await store.append({ type: 'alpha', payload: {}, actor: 'user' })
462
+
463
+ const types = await store.types()
464
+ expect(types).toContain('alpha')
465
+ expect(types).toContain('beta')
466
+ expect(types.length).toBe(2)
467
+ })
468
+
469
+ it('sessions() returns all distinct session IDs', async () => {
470
+ await store.append({ type: 'x', payload: {}, actor: 'user', sessionId: 'sess-1' })
471
+ await store.append({ type: 'x', payload: {}, actor: 'user', sessionId: 'sess-2' })
472
+ await store.append({ type: 'x', payload: {}, actor: 'user' }) // no session
473
+
474
+ const sessions = await store.sessions()
475
+ expect(sessions).toContain('sess-1')
476
+ expect(sessions).toContain('sess-2')
477
+ expect(sessions.length).toBe(2)
478
+ })
479
+
480
+ it('close() does not throw', async () => {
481
+ await expect(store.close()).resolves.not.toThrow()
482
+ })
483
+ })
moav2/src/__tests__/model-resolver-openai-oauth.test.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ vi.mock('@mariozechner/pi-ai', () => ({
4
+ getModel: vi.fn((provider: string, id: string) => {
5
+ if (provider === 'google-vertex') return { id, provider, api: 'google-vertex' }
6
+ return { id, provider, api: 'openai-responses' }
7
+ }),
8
+ getProviders: vi.fn(() => ['openai-codex']),
9
+ getModels: vi.fn(() => [{ id: 'gpt-5', provider: 'openai-codex', api: 'openai-responses' }]),
10
+ }))
11
+
12
+ import { resolveModel } from '../core/services/model-resolver'
13
+
14
+ describe('model resolver openai oauth', () => {
15
+ it('resolves openai-oauth via openai-codex provider registry', async () => {
16
+ const model = await resolveModel({ modelId: 'gpt-5', authMethod: 'openai-oauth' })
17
+ expect(model.provider).toBe('openai-codex')
18
+ })
19
+
20
+ it('resolves anthropic-oauth via anthropic provider registry', async () => {
21
+ const model = await resolveModel({ modelId: 'claude-3-7-sonnet', authMethod: 'anthropic-oauth' })
22
+ expect(model.provider).toBe('anthropic')
23
+ })
24
+
25
+ it('resolves vertex via google-vertex provider registry', async () => {
26
+ const model = await resolveModel({ modelId: 'gemini-2.5-pro', authMethod: 'vertex' })
27
+ expect(model.provider).toBe('google-vertex')
28
+ })
29
+
30
+ it('resolves vertex-express using google-vertex registry model', async () => {
31
+ const model = await resolveModel({ modelId: 'gemini-2.5-pro', authMethod: 'vertex-express' })
32
+ expect(model.api).toBe('google-vertex')
33
+ expect(model.provider).toBe('google-vertex')
34
+ })
35
+ })
moav2/src/__tests__/oauth-code-bridge.test.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { OAuthCodeBridge } from '../core/services/oauth-code-bridge'
3
+
4
+ describe('OAuthCodeBridge', () => {
5
+ it('submits immediately when resolver is attached', () => {
6
+ const bridge = new OAuthCodeBridge()
7
+ const resolve = vi.fn()
8
+ const reject = vi.fn()
9
+
10
+ bridge.attachResolver({ resolve, reject })
11
+ const result = bridge.submitCode('abc123')
12
+
13
+ expect(result).toEqual({ accepted: true, queued: false })
14
+ expect(resolve).toHaveBeenCalledWith('abc123')
15
+ expect(reject).not.toHaveBeenCalled()
16
+ })
17
+
18
+ it('queues code when submitted before resolver and auto-submits when resolver appears', () => {
19
+ const bridge = new OAuthCodeBridge()
20
+ const resolve = vi.fn()
21
+ const reject = vi.fn()
22
+
23
+ const early = bridge.submitCode('late-resolver-code')
24
+ const flushed = bridge.attachResolver({ resolve, reject })
25
+
26
+ expect(early).toEqual({ accepted: false, queued: true })
27
+ expect(flushed).toBe(true)
28
+ expect(resolve).toHaveBeenCalledWith('late-resolver-code')
29
+ expect(reject).not.toHaveBeenCalled()
30
+ })
31
+
32
+ it('cancels pending work and rejects active resolver', () => {
33
+ const bridge = new OAuthCodeBridge()
34
+ const resolve = vi.fn()
35
+ const reject = vi.fn()
36
+
37
+ bridge.attachResolver({ resolve, reject })
38
+ bridge.cancel('flow reset')
39
+
40
+ expect(reject).toHaveBeenCalledOnce()
41
+ expect(resolve).not.toHaveBeenCalled()
42
+ })
43
+ })
moav2/src/__tests__/pin-system.test.ts ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pin system tests.
3
+ *
4
+ * Tests the tab sorting logic used across sessions, terminal tabs, and
5
+ * browser tabs. The core `sortTabs` function lives in db.ts but is not
6
+ * exported, so we replicate it here for direct unit testing.
7
+ *
8
+ * We also test the pin/unpin sort-order assignment logic that lives in
9
+ * App.tsx (pinSession, pinTerminalTab, pinBrowserTab).
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Replicated sortTabs from db.ts (not exported)
15
+ // ---------------------------------------------------------------------------
16
+
17
+ interface TabLike {
18
+ id: string
19
+ pinned: boolean
20
+ sortOrder: number
21
+ }
22
+
23
+ /** Sort tabs: pinned first (by sortOrder), then unpinned (by sortOrder) */
24
+ function sortTabs<T extends TabLike>(tabs: T[]): T[] {
25
+ return [...tabs].sort((a, b) => {
26
+ if (a.pinned && !b.pinned) return -1
27
+ if (!a.pinned && b.pinned) return 1
28
+ return a.sortOrder - b.sortOrder
29
+ })
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // sortTabs tests
34
+ // ---------------------------------------------------------------------------
35
+
36
+ describe('sortTabs', () => {
37
+ it('places pinned tabs before unpinned tabs', () => {
38
+ const tabs: TabLike[] = [
39
+ { id: 'u1', pinned: false, sortOrder: 0 },
40
+ { id: 'p1', pinned: true, sortOrder: 0 },
41
+ { id: 'u2', pinned: false, sortOrder: 1 },
42
+ ]
43
+ const sorted = sortTabs(tabs)
44
+ expect(sorted[0].id).toBe('p1')
45
+ expect(sorted[1].id).toBe('u1')
46
+ expect(sorted[2].id).toBe('u2')
47
+ })
48
+
49
+ it('sorts pinned tabs by sortOrder among themselves', () => {
50
+ const tabs: TabLike[] = [
51
+ { id: 'p2', pinned: true, sortOrder: 2 },
52
+ { id: 'p1', pinned: true, sortOrder: 1 },
53
+ { id: 'p3', pinned: true, sortOrder: 3 },
54
+ ]
55
+ const sorted = sortTabs(tabs)
56
+ expect(sorted.map(t => t.id)).toEqual(['p1', 'p2', 'p3'])
57
+ })
58
+
59
+ it('sorts unpinned tabs by sortOrder among themselves', () => {
60
+ const tabs: TabLike[] = [
61
+ { id: 'u3', pinned: false, sortOrder: 3 },
62
+ { id: 'u1', pinned: false, sortOrder: 1 },
63
+ { id: 'u2', pinned: false, sortOrder: 2 },
64
+ ]
65
+ const sorted = sortTabs(tabs)
66
+ expect(sorted.map(t => t.id)).toEqual(['u1', 'u2', 'u3'])
67
+ })
68
+
69
+ it('handles empty array', () => {
70
+ expect(sortTabs([])).toEqual([])
71
+ })
72
+
73
+ it('handles single pinned tab', () => {
74
+ const tabs: TabLike[] = [{ id: 'p1', pinned: true, sortOrder: 0 }]
75
+ expect(sortTabs(tabs)).toEqual(tabs)
76
+ })
77
+
78
+ it('handles single unpinned tab', () => {
79
+ const tabs: TabLike[] = [{ id: 'u1', pinned: false, sortOrder: 0 }]
80
+ expect(sortTabs(tabs)).toEqual(tabs)
81
+ })
82
+
83
+ it('does not mutate the input array', () => {
84
+ const tabs: TabLike[] = [
85
+ { id: 'u1', pinned: false, sortOrder: 0 },
86
+ { id: 'p1', pinned: true, sortOrder: 0 },
87
+ ]
88
+ const original = [...tabs]
89
+ sortTabs(tabs)
90
+ expect(tabs).toEqual(original)
91
+ })
92
+
93
+ it('correctly interleaves when pinned sortOrders are higher than unpinned', () => {
94
+ // Pinned tabs should still come first regardless of absolute sortOrder value
95
+ const tabs: TabLike[] = [
96
+ { id: 'u1', pinned: false, sortOrder: 0 },
97
+ { id: 'p1', pinned: true, sortOrder: 100 },
98
+ { id: 'u2', pinned: false, sortOrder: 1 },
99
+ { id: 'p2', pinned: true, sortOrder: 50 },
100
+ ]
101
+ const sorted = sortTabs(tabs)
102
+ // Pinned first (sorted by sortOrder: 50, 100), then unpinned (0, 1)
103
+ expect(sorted.map(t => t.id)).toEqual(['p2', 'p1', 'u1', 'u2'])
104
+ })
105
+
106
+ it('preserves stable order for tabs with equal sortOrder and pin state', () => {
107
+ // JavaScript sort is not guaranteed to be stable in all engines,
108
+ // but modern V8 (used by Node/Vitest) is stable. We test the
109
+ // expected behavior.
110
+ const tabs: TabLike[] = [
111
+ { id: 'a', pinned: false, sortOrder: 0 },
112
+ { id: 'b', pinned: false, sortOrder: 0 },
113
+ ]
114
+ const sorted = sortTabs(tabs)
115
+ // With equal sortOrder, order should be stable (a before b)
116
+ expect(sorted[0].id).toBe('a')
117
+ expect(sorted[1].id).toBe('b')
118
+ })
119
+ })
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Pin assignment logic (from App.tsx pinSession/pinTerminalTab/pinBrowserTab)
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe('pin sortOrder assignment', () => {
126
+ // When pinning a tab, the app sets:
127
+ // sortOrder = max(pinned sortOrders) + 1
128
+ // This places the newly pinned tab at the bottom of the pinned section.
129
+
130
+ function computePinSortOrder(tabs: TabLike[]): number {
131
+ const pinnedTabs = tabs.filter(t => t.pinned)
132
+ const maxPinnedOrder = pinnedTabs.length > 0
133
+ ? Math.max(...pinnedTabs.map(t => t.sortOrder))
134
+ : -1
135
+ return maxPinnedOrder + 1
136
+ }
137
+
138
+ it('assigns sortOrder 0 when no tabs are pinned', () => {
139
+ const tabs: TabLike[] = [
140
+ { id: 'u1', pinned: false, sortOrder: 0 },
141
+ { id: 'u2', pinned: false, sortOrder: 1 },
142
+ ]
143
+ expect(computePinSortOrder(tabs)).toBe(0)
144
+ })
145
+
146
+ it('assigns sortOrder = max + 1 when tabs are already pinned', () => {
147
+ const tabs: TabLike[] = [
148
+ { id: 'p1', pinned: true, sortOrder: 0 },
149
+ { id: 'p2', pinned: true, sortOrder: 3 },
150
+ { id: 'u1', pinned: false, sortOrder: 5 },
151
+ ]
152
+ // max pinned sortOrder is 3, so new pin gets 4
153
+ expect(computePinSortOrder(tabs)).toBe(4)
154
+ })
155
+
156
+ it('assigns correct sortOrder with single pinned tab', () => {
157
+ const tabs: TabLike[] = [
158
+ { id: 'p1', pinned: true, sortOrder: 7 },
159
+ ]
160
+ expect(computePinSortOrder(tabs)).toBe(8)
161
+ })
162
+
163
+ it('ignores unpinned sortOrders when computing pin position', () => {
164
+ const tabs: TabLike[] = [
165
+ { id: 'u1', pinned: false, sortOrder: 100 },
166
+ { id: 'p1', pinned: true, sortOrder: 2 },
167
+ ]
168
+ // Only pinned sortOrders matter; max is 2
169
+ expect(computePinSortOrder(tabs)).toBe(3)
170
+ })
171
+ })
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Reorder logic (from App.tsx reorderSessions/reorderTerminalTabs etc.)
175
+ // ---------------------------------------------------------------------------
176
+
177
+ describe('reorder tabs', () => {
178
+ // After drag-and-drop, the app:
179
+ // 1. Splices the item from fromIndex, inserts at toIndex
180
+ // 2. Recalculates sortOrder = index for all items
181
+
182
+ function reorderTabs(tabs: TabLike[], fromIndex: number, toIndex: number): TabLike[] {
183
+ const reordered = [...tabs]
184
+ const [moved] = reordered.splice(fromIndex, 1)
185
+ reordered.splice(toIndex, 0, moved)
186
+ return reordered.map((t, i) => ({ ...t, sortOrder: i }))
187
+ }
188
+
189
+ it('moves tab forward (lower to higher index)', () => {
190
+ const tabs: TabLike[] = [
191
+ { id: 'a', pinned: false, sortOrder: 0 },
192
+ { id: 'b', pinned: false, sortOrder: 1 },
193
+ { id: 'c', pinned: false, sortOrder: 2 },
194
+ ]
195
+ const result = reorderTabs(tabs, 0, 2)
196
+ expect(result.map(t => t.id)).toEqual(['b', 'c', 'a'])
197
+ expect(result.map(t => t.sortOrder)).toEqual([0, 1, 2])
198
+ })
199
+
200
+ it('moves tab backward (higher to lower index)', () => {
201
+ const tabs: TabLike[] = [
202
+ { id: 'a', pinned: false, sortOrder: 0 },
203
+ { id: 'b', pinned: false, sortOrder: 1 },
204
+ { id: 'c', pinned: false, sortOrder: 2 },
205
+ ]
206
+ const result = reorderTabs(tabs, 2, 0)
207
+ expect(result.map(t => t.id)).toEqual(['c', 'a', 'b'])
208
+ expect(result.map(t => t.sortOrder)).toEqual([0, 1, 2])
209
+ })
210
+
211
+ it('no-op when from and to are the same', () => {
212
+ const tabs: TabLike[] = [
213
+ { id: 'a', pinned: false, sortOrder: 0 },
214
+ { id: 'b', pinned: false, sortOrder: 1 },
215
+ ]
216
+ const result = reorderTabs(tabs, 1, 1)
217
+ expect(result.map(t => t.id)).toEqual(['a', 'b'])
218
+ expect(result.map(t => t.sortOrder)).toEqual([0, 1])
219
+ })
220
+
221
+ it('recalculates all sortOrders sequentially', () => {
222
+ const tabs: TabLike[] = [
223
+ { id: 'a', pinned: true, sortOrder: 10 },
224
+ { id: 'b', pinned: true, sortOrder: 20 },
225
+ { id: 'c', pinned: false, sortOrder: 30 },
226
+ ]
227
+ const result = reorderTabs(tabs, 1, 0)
228
+ // After reorder: [b, a, c], all sortOrders become 0, 1, 2
229
+ expect(result.map(t => t.sortOrder)).toEqual([0, 1, 2])
230
+ })
231
+ })
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Combined pin + sort scenario tests
235
+ // ---------------------------------------------------------------------------
236
+
237
+ describe('pin + sort integration', () => {
238
+ it('newly pinned tab appears at bottom of pinned section after re-sort', () => {
239
+ // Scenario: 2 pinned (sortOrder 0, 1), 2 unpinned (sortOrder 0, 1)
240
+ // Pin the first unpinned tab -> it gets sortOrder = max(1) + 1 = 2
241
+ const tabs: TabLike[] = [
242
+ { id: 'p1', pinned: true, sortOrder: 0 },
243
+ { id: 'p2', pinned: true, sortOrder: 1 },
244
+ { id: 'u1', pinned: false, sortOrder: 0 },
245
+ { id: 'u2', pinned: false, sortOrder: 1 },
246
+ ]
247
+
248
+ // Simulate pinning u1
249
+ const pinnedSortOrder = Math.max(...tabs.filter(t => t.pinned).map(t => t.sortOrder)) + 1
250
+ const updated = tabs.map(t =>
251
+ t.id === 'u1' ? { ...t, pinned: true, sortOrder: pinnedSortOrder } : t
252
+ )
253
+
254
+ const sorted = sortTabs(updated)
255
+ // Expected order: p1(pin,0), p2(pin,1), u1(pin,2), u2(unpin,1)
256
+ expect(sorted.map(t => t.id)).toEqual(['p1', 'p2', 'u1', 'u2'])
257
+ // u1 is now the last pinned item
258
+ const pinnedIds = sorted.filter(t => t.pinned).map(t => t.id)
259
+ expect(pinnedIds).toEqual(['p1', 'p2', 'u1'])
260
+ })
261
+
262
+ it('unpinned tab falls to the unpinned section after re-sort', () => {
263
+ const tabs: TabLike[] = [
264
+ { id: 'p1', pinned: true, sortOrder: 0 },
265
+ { id: 'p2', pinned: true, sortOrder: 1 },
266
+ { id: 'u1', pinned: false, sortOrder: 0 },
267
+ ]
268
+
269
+ // Unpin p2 (just set pinned: false, keep sortOrder)
270
+ const updated = tabs.map(t =>
271
+ t.id === 'p2' ? { ...t, pinned: false } : t
272
+ )
273
+
274
+ const sorted = sortTabs(updated)
275
+ // p1 stays pinned first, then unpinned sorted by sortOrder: u1(0), p2(1)
276
+ expect(sorted[0].id).toBe('p1')
277
+ expect(sorted[0].pinned).toBe(true)
278
+ // Unpinned section: u1 has sortOrder 0, p2 has sortOrder 1
279
+ expect(sorted[1].id).toBe('u1')
280
+ expect(sorted[2].id).toBe('p2')
281
+ })
282
+
283
+ it('handles all tabs pinned', () => {
284
+ const tabs: TabLike[] = [
285
+ { id: 'a', pinned: true, sortOrder: 2 },
286
+ { id: 'b', pinned: true, sortOrder: 0 },
287
+ { id: 'c', pinned: true, sortOrder: 1 },
288
+ ]
289
+ const sorted = sortTabs(tabs)
290
+ expect(sorted.map(t => t.id)).toEqual(['b', 'c', 'a'])
291
+ })
292
+
293
+ it('handles all tabs unpinned', () => {
294
+ const tabs: TabLike[] = [
295
+ { id: 'c', pinned: false, sortOrder: 2 },
296
+ { id: 'a', pinned: false, sortOrder: 0 },
297
+ { id: 'b', pinned: false, sortOrder: 1 },
298
+ ]
299
+ const sorted = sortTabs(tabs)
300
+ expect(sorted.map(t => t.id)).toEqual(['a', 'b', 'c'])
301
+ })
302
+ })
moav2/src/__tests__/platform-boot.test.ts ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Platform Boot tests — verifies browser platform init, fs, path, process, shell.
3
+ * Tests the browser platform backed by @zenfs/core (IndexedDB via fake-indexeddb in jsdom).
4
+ */
5
+ import { describe, it, expect, beforeAll } from 'vitest'
6
+ import 'fake-indexeddb/auto'
7
+ import { setPlatform, getPlatform } from '../core/platform'
8
+ import { createBrowserPlatform } from '../platform/browser'
9
+
10
+ describe('Platform Boot', () => {
11
+ beforeAll(async () => {
12
+ const platform = await createBrowserPlatform()
13
+ setPlatform(platform)
14
+ })
15
+
16
+ it('initializes browser platform', () => {
17
+ expect(getPlatform().type).toBe('browser')
18
+ })
19
+
20
+ it('has filesystem with read/write', () => {
21
+ const { fs } = getPlatform()
22
+ fs.mkdirSync('/test-boot', { recursive: true })
23
+ fs.writeFileSync('/test-boot/hello.txt', 'world')
24
+ expect(fs.readFileSync('/test-boot/hello.txt', 'utf-8')).toBe('world')
25
+ })
26
+
27
+ it('has filesystem existsSync', () => {
28
+ const { fs } = getPlatform()
29
+ expect(fs.existsSync('/test-boot/hello.txt')).toBe(true)
30
+ expect(fs.existsSync('/test-boot/nope.txt')).toBe(false)
31
+ })
32
+
33
+ it('has filesystem readdirSync', () => {
34
+ const { fs } = getPlatform()
35
+ const entries = fs.readdirSync('/test-boot')
36
+ expect(entries).toContain('hello.txt')
37
+ })
38
+
39
+ it('has filesystem statSync', () => {
40
+ const { fs } = getPlatform()
41
+ const stat = fs.statSync('/test-boot/hello.txt')
42
+ expect(stat.isFile()).toBe(true)
43
+ expect(stat.isDirectory()).toBe(false)
44
+ expect(stat.size).toBeGreaterThan(0)
45
+ })
46
+
47
+ it('has filesystem unlinkSync', () => {
48
+ const { fs } = getPlatform()
49
+ fs.writeFileSync('/test-boot/to-delete.txt', 'bye')
50
+ expect(fs.existsSync('/test-boot/to-delete.txt')).toBe(true)
51
+ fs.unlinkSync('/test-boot/to-delete.txt')
52
+ expect(fs.existsSync('/test-boot/to-delete.txt')).toBe(false)
53
+ })
54
+
55
+ it('has async readFile/writeFile', async () => {
56
+ const { fs } = getPlatform()
57
+ fs.mkdirSync('/test-async', { recursive: true })
58
+ await fs.writeFile('/test-async/data.txt', 'async-content')
59
+ const content = await fs.readFile('/test-async/data.txt', 'utf-8')
60
+ expect(content).toBe('async-content')
61
+ })
62
+
63
+ it('has path utilities', () => {
64
+ const { path } = getPlatform()
65
+ expect(path.join('/a', 'b', 'c')).toBe('/a/b/c')
66
+ expect(path.dirname('/a/b/c.txt')).toBe('/a/b')
67
+ expect(path.basename('/a/b/c.txt')).toBe('c.txt')
68
+ expect(path.basename('/a/b/c.txt', '.txt')).toBe('c')
69
+ expect(path.extname('/a/b/c.txt')).toBe('.txt')
70
+ expect(path.sep).toBe('/')
71
+ })
72
+
73
+ it('has path.resolve', () => {
74
+ const { path } = getPlatform()
75
+ // resolve should produce an absolute path
76
+ const resolved = path.resolve('/a', 'b', 'c')
77
+ expect(resolved).toBe('/a/b/c')
78
+ })
79
+
80
+ it('has process stub', () => {
81
+ const { process } = getPlatform()
82
+ expect(process.cwd()).toBe('/')
83
+ expect(process.homedir()).toBe('/')
84
+ expect(typeof process.env).toBe('object')
85
+ })
86
+
87
+ it('has process.exec that returns browser mode message', async () => {
88
+ const { process } = getPlatform()
89
+ const result = await process.exec('ls -la')
90
+ expect(result.stdout).toContain('Browser mode')
91
+ expect(result.exitCode).toBe(1)
92
+ })
93
+
94
+ it('has process.execSync that returns browser mode message', () => {
95
+ const { process } = getPlatform()
96
+ const result = process.execSync('ls -la')
97
+ expect(result).toContain('Browser mode')
98
+ })
99
+
100
+ it('has shell with openExternal', () => {
101
+ expect(getPlatform().shell.openExternal).toBeDefined()
102
+ expect(typeof getPlatform().shell.openExternal).toBe('function')
103
+ })
104
+
105
+ it('has sqlite interface', () => {
106
+ expect(getPlatform().sqlite).toBeDefined()
107
+ expect(typeof getPlatform().sqlite.open).toBe('function')
108
+ })
109
+ })
moav2/src/__tests__/provider-smoke.test.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ vi.mock('@mariozechner/pi-ai', () => ({
4
+ getModel: vi.fn((provider: string, id: string) => ({ id, provider, api: provider === 'google-vertex' ? 'google-vertex' : 'openai-responses' })),
5
+ getProviders: vi.fn(() => ['anthropic', 'openai-codex', 'google-vertex', 'custom-provider']),
6
+ getModels: vi.fn((provider: string) => [{ id: 'provider-model', provider, api: 'openai-responses' }]),
7
+ }))
8
+
9
+ import { resolveModel } from '../core/services/model-resolver'
10
+ import { getAnthropicBrowserAuthError, resolveVertexFallback } from '../core/services/provider-guards'
11
+
12
+ describe('provider smoke tests', () => {
13
+ it('falls back vertex to vertex-express when express key exists', () => {
14
+ const resolved = resolveVertexFallback('vertex:gemini-2.5-pro', null, null, 'vx-123')
15
+ expect(resolved.authMethod).toBe('vertex-express')
16
+ expect(resolved.modelId).toBe('gemini-2.5-pro')
17
+ })
18
+
19
+ it('shows a friendly error when vertex has no credentials', () => {
20
+ expect(() => resolveVertexFallback('vertex:gemini-2.5-pro', null, null, null)).toThrow(
21
+ 'Vertex AI is not configured. Add Vertex project/location or set a Vertex Express API key in Settings > Vertex AI.',
22
+ )
23
+ })
24
+
25
+ it('blocks anthropic api key usage in browser mode', () => {
26
+ expect(getAnthropicBrowserAuthError('anthropic-key', 'browser')).toContain('switch to OAuth')
27
+ expect(getAnthropicBrowserAuthError('anthropic-oauth', 'browser')).toBeNull()
28
+ })
29
+
30
+ it('resolves anthropic oauth model', async () => {
31
+ const model = await resolveModel({ modelId: 'claude-3-7-sonnet', authMethod: 'anthropic-oauth' })
32
+ expect(model.provider).toBe('anthropic')
33
+ })
34
+
35
+ it('resolves openai oauth model', async () => {
36
+ const model = await resolveModel({ modelId: 'gpt-5', authMethod: 'openai-oauth' })
37
+ expect(model.provider).toBe('openai-codex')
38
+ })
39
+
40
+ it('resolves vertex express model', async () => {
41
+ const model = await resolveModel({ modelId: 'gemini-2.5-pro', authMethod: 'vertex-express' })
42
+ expect(model.provider).toBe('google-vertex')
43
+ })
44
+
45
+ it('resolves custom provider model', async () => {
46
+ const model = await resolveModel({ modelId: 'unregistered-model', authMethod: 'custom-provider', providerBaseUrl: 'https://example.com' })
47
+ expect(model.provider).toBe('custom-provider')
48
+ })
49
+ })
moav2/src/__tests__/retry.test.ts ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
+ import { isRetryableError, sleep, withRetry } from '../core/services/retry'
3
+
4
+ afterEach(() => {
5
+ vi.restoreAllMocks()
6
+ })
7
+
8
+ describe('isRetryableError', () => {
9
+ it('retries for status 429 and 5xx', () => {
10
+ expect(isRetryableError({ status: 429 })).toBe(true)
11
+ expect(isRetryableError({ statusCode: 503 })).toBe(true)
12
+ expect(isRetryableError({ response: { status: 500 } })).toBe(true)
13
+ })
14
+
15
+ it('retries for common retryable keywords', () => {
16
+ expect(isRetryableError({ message: 'RESOURCE_EXHAUSTED' })).toBe(true)
17
+ expect(isRetryableError({ message: 'DEADLINE_EXCEEDED' })).toBe(true)
18
+ expect(isRetryableError({ message: 'fetch failed' })).toBe(true)
19
+ expect(isRetryableError({ message: 'rate limit exceeded' })).toBe(true)
20
+ expect(isRetryableError({ message: 'At capacity right now' })).toBe(true)
21
+ expect(isRetryableError({ code: 'ECONNREFUSED' })).toBe(true)
22
+ })
23
+
24
+ it('does not retry for non-retryable errors', () => {
25
+ expect(isRetryableError({ status: 401, message: 'Invalid API key' })).toBe(false)
26
+ expect(isRetryableError({ message: 'Model not found' })).toBe(false)
27
+ })
28
+ })
29
+
30
+ describe('sleep', () => {
31
+ it('resolves after delay', async () => {
32
+ const start = Date.now()
33
+ await sleep(5)
34
+ expect(Date.now() - start).toBeGreaterThanOrEqual(1)
35
+ })
36
+
37
+ it('rejects when aborted', async () => {
38
+ const controller = new AbortController()
39
+ const pending = sleep(50, controller.signal)
40
+ controller.abort()
41
+ await expect(pending).rejects.toThrow('Request was aborted')
42
+ })
43
+ })
44
+
45
+ describe('withRetry', () => {
46
+ it('retries retryable failures and eventually succeeds', async () => {
47
+ const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
48
+ const fn = vi
49
+ .fn<() => Promise<string>>()
50
+ .mockRejectedValueOnce(new Error('rate limit exceeded'))
51
+ .mockRejectedValueOnce(new Error('Server overloaded'))
52
+ .mockResolvedValue('ok')
53
+
54
+ const onRetry = vi.fn()
55
+ const result = await withRetry(fn, {
56
+ maxRetries: 4,
57
+ initialDelayMs: 1,
58
+ maxDelayMs: 10,
59
+ jitterRatio: 0,
60
+ onRetry,
61
+ })
62
+
63
+ expect(result).toBe('ok')
64
+ expect(fn).toHaveBeenCalledTimes(3)
65
+ expect(onRetry).toHaveBeenCalledTimes(2)
66
+ expect(onRetry).toHaveBeenNthCalledWith(
67
+ 1,
68
+ expect.objectContaining({ attempt: 1, maxRetries: 4, delayMs: 1 })
69
+ )
70
+ expect(onRetry).toHaveBeenNthCalledWith(
71
+ 2,
72
+ expect.objectContaining({ attempt: 2, maxRetries: 4, delayMs: 2 })
73
+ )
74
+ randomSpy.mockRestore()
75
+ })
76
+
77
+ it('does not retry non-retryable failures', async () => {
78
+ const error = new Error('Invalid API key (401)')
79
+ const fn = vi.fn<() => Promise<string>>().mockRejectedValue(error)
80
+
81
+ await expect(
82
+ withRetry(fn, {
83
+ initialDelayMs: 1,
84
+ jitterRatio: 0,
85
+ })
86
+ ).rejects.toThrow('Invalid API key')
87
+ expect(fn).toHaveBeenCalledTimes(1)
88
+ })
89
+
90
+ it('respects max retries', async () => {
91
+ const fn = vi.fn<() => Promise<string>>().mockRejectedValue(new Error('fetch failed'))
92
+
93
+ await expect(
94
+ withRetry(fn, {
95
+ maxRetries: 2,
96
+ initialDelayMs: 1,
97
+ maxDelayMs: 5,
98
+ jitterRatio: 0,
99
+ })
100
+ ).rejects.toThrow('fetch failed')
101
+
102
+ expect(fn).toHaveBeenCalledTimes(3)
103
+ })
104
+
105
+ it('stops retries when aborted', async () => {
106
+ const controller = new AbortController()
107
+ const fn = vi.fn<() => Promise<string>>().mockRejectedValue(new Error('fetch failed'))
108
+
109
+ await expect(
110
+ withRetry(fn, {
111
+ maxRetries: 4,
112
+ initialDelayMs: 50,
113
+ jitterRatio: 0,
114
+ signal: controller.signal,
115
+ onRetry: () => controller.abort(),
116
+ })
117
+ ).rejects.toThrow('Request was aborted')
118
+
119
+ expect(fn).toHaveBeenCalledTimes(1)
120
+ })
121
+ })
moav2/src/__tests__/scroll-behavior.test.ts ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Scroll behavior tests for AgentBuffer.
3
+ *
4
+ * The AgentBuffer component uses a useEffect that calls
5
+ * messagesEndRef.current?.scrollIntoView({ behavior }) where behavior
6
+ * is 'instant' during streaming and 'smooth' otherwise.
7
+ *
8
+ * We also test the pure helper `systemMessageClass` which classifies
9
+ * system message content for CSS styling.
10
+ */
11
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
12
+ import { isRetryableError } from '../core/services/retry'
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // 1. systemMessageClass — pure function extracted from AgentBuffer.tsx
16
+ // ---------------------------------------------------------------------------
17
+
18
+ // The function is defined inline in AgentBuffer.tsx (not exported), so we
19
+ // replicate it here for direct unit testing. If it ever gets extracted and
20
+ // exported, swap this for a direct import.
21
+ function systemMessageClass(content: string): string {
22
+ const lower = content.toLowerCase()
23
+ if (
24
+ lower.includes('error') ||
25
+ lower.includes('failed') ||
26
+ lower.includes('not ready') ||
27
+ lower.includes('not found') ||
28
+ lower.includes('denied') ||
29
+ lower.includes('authentication') ||
30
+ lower.includes('not configured') ||
31
+ lower.includes('no api key')
32
+ ) {
33
+ return 'system-error'
34
+ }
35
+ if (lower.includes('retrying')) {
36
+ return 'system-retry'
37
+ }
38
+ return ''
39
+ }
40
+
41
+ describe('systemMessageClass', () => {
42
+ it('returns "system-error" for messages containing "error"', () => {
43
+ expect(systemMessageClass('Something went wrong: error')).toBe('system-error')
44
+ })
45
+
46
+ it('returns "system-error" for "failed"', () => {
47
+ expect(systemMessageClass('Request failed with status 500')).toBe('system-error')
48
+ })
49
+
50
+ it('returns "system-error" for "not ready"', () => {
51
+ expect(systemMessageClass('Agent is not ready')).toBe('system-error')
52
+ })
53
+
54
+ it('returns "system-error" for "not found"', () => {
55
+ expect(systemMessageClass('Model not found')).toBe('system-error')
56
+ })
57
+
58
+ it('returns "system-error" for "denied"', () => {
59
+ expect(systemMessageClass('Permission denied')).toBe('system-error')
60
+ })
61
+
62
+ it('returns "system-error" for "authentication"', () => {
63
+ expect(systemMessageClass('Authentication failed')).toBe('system-error')
64
+ })
65
+
66
+ it('returns "system-error" for "not configured"', () => {
67
+ expect(systemMessageClass('Provider not configured')).toBe('system-error')
68
+ })
69
+
70
+ it('returns "system-error" for "no api key"', () => {
71
+ expect(systemMessageClass('No API key set')).toBe('system-error')
72
+ })
73
+
74
+ it('returns "system-retry" for "retrying"', () => {
75
+ expect(systemMessageClass('Retrying... (attempt 2/3)')).toBe('system-retry')
76
+ })
77
+
78
+ it('returns empty string for neutral system messages', () => {
79
+ expect(systemMessageClass('Session started')).toBe('')
80
+ })
81
+
82
+ it('is case-insensitive', () => {
83
+ expect(systemMessageClass('ERROR: something broke')).toBe('system-error')
84
+ expect(systemMessageClass('RETRYING connection')).toBe('system-retry')
85
+ })
86
+
87
+ it('prioritizes error over retry when both present', () => {
88
+ // "error" check comes first in the if-chain
89
+ expect(systemMessageClass('Error: retrying...')).toBe('system-error')
90
+ })
91
+ })
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // 2. Scroll behavior logic — unit test of the scroll decision
95
+ // ---------------------------------------------------------------------------
96
+
97
+ // The actual useEffect in AgentBuffer does:
98
+ // messagesEndRef.current?.scrollIntoView({
99
+ // behavior: state.isStreaming ? 'instant' : 'smooth'
100
+ // })
101
+ //
102
+ // We test the logic that determines scroll behavior separately from the
103
+ // React component to keep these tests fast and deterministic.
104
+
105
+ describe('scroll behavior decision', () => {
106
+ function resolveScrollBehavior(isStreaming: boolean): ScrollBehavior {
107
+ return isStreaming ? 'instant' : 'smooth'
108
+ }
109
+
110
+ it('uses instant scroll during streaming', () => {
111
+ expect(resolveScrollBehavior(true)).toBe('instant')
112
+ })
113
+
114
+ it('uses smooth scroll after streaming ends', () => {
115
+ expect(resolveScrollBehavior(false)).toBe('smooth')
116
+ })
117
+ })
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // 3. Scroll trigger conditions — the effect depends on specific state
121
+ // ---------------------------------------------------------------------------
122
+
123
+ describe('scroll trigger conditions', () => {
124
+ // The useEffect dependency array is:
125
+ // [state.messages.length, state.streamingBlocks.length, state.isStreaming]
126
+ // We simulate state transitions and verify the effect would fire.
127
+
128
+ interface ScrollState {
129
+ messagesLength: number
130
+ streamingBlocksLength: number
131
+ isStreaming: boolean
132
+ }
133
+
134
+ function shouldScroll(prev: ScrollState, next: ScrollState): boolean {
135
+ // The effect fires when any dep changes
136
+ return (
137
+ prev.messagesLength !== next.messagesLength ||
138
+ prev.streamingBlocksLength !== next.streamingBlocksLength ||
139
+ prev.isStreaming !== next.isStreaming
140
+ )
141
+ }
142
+
143
+ it('triggers scroll when a new message is added', () => {
144
+ const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false }
145
+ const next: ScrollState = { messagesLength: 3, streamingBlocksLength: 0, isStreaming: false }
146
+ expect(shouldScroll(prev, next)).toBe(true)
147
+ })
148
+
149
+ it('triggers scroll when streaming blocks update', () => {
150
+ const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 1, isStreaming: true }
151
+ const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 2, isStreaming: true }
152
+ expect(shouldScroll(prev, next)).toBe(true)
153
+ })
154
+
155
+ it('triggers scroll when streaming state changes', () => {
156
+ const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 3, isStreaming: true }
157
+ const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 3, isStreaming: false }
158
+ expect(shouldScroll(prev, next)).toBe(true)
159
+ })
160
+
161
+ it('does not trigger scroll when nothing changes', () => {
162
+ const prev: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false }
163
+ const next: ScrollState = { messagesLength: 2, streamingBlocksLength: 0, isStreaming: false }
164
+ expect(shouldScroll(prev, next)).toBe(false)
165
+ })
166
+ })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // 4. Session switch — empty session hero text
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('empty session detection', () => {
173
+ // In AgentBuffer, the hero text is shown when:
174
+ // const hasContent = state.messages.length > 0 || state.streamingBlocks.length > 0
175
+ // {!hasContent ? <div className="empty-chat"> ... }
176
+
177
+ function hasContent(messagesLength: number, streamingBlocksLength: number): boolean {
178
+ return messagesLength > 0 || streamingBlocksLength > 0
179
+ }
180
+
181
+ it('shows hero text when no messages and no streaming blocks', () => {
182
+ expect(hasContent(0, 0)).toBe(false)
183
+ })
184
+
185
+ it('hides hero text when messages exist', () => {
186
+ expect(hasContent(1, 0)).toBe(true)
187
+ })
188
+
189
+ it('hides hero text when streaming blocks exist', () => {
190
+ expect(hasContent(0, 1)).toBe(true)
191
+ })
192
+
193
+ it('hides hero text when both exist', () => {
194
+ expect(hasContent(3, 2)).toBe(true)
195
+ })
196
+ })
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // 5. SessionStore (unit tests of subscribe/notify)
200
+ // ---------------------------------------------------------------------------
201
+
202
+ describe('SessionStore subscribe/notify', () => {
203
+ // We test the core subscribe/notify contract without importing the real
204
+ // SessionStore (which has heavy dependencies on agent-service, db, etc.)
205
+
206
+ class MinimalStore {
207
+ private listeners = new Set<() => void>()
208
+ subscribe(listener: () => void): () => void {
209
+ this.listeners.add(listener)
210
+ return () => this.listeners.delete(listener)
211
+ }
212
+ notify() {
213
+ for (const listener of this.listeners) listener()
214
+ }
215
+ }
216
+
217
+ it('notifies all subscribers', () => {
218
+ const store = new MinimalStore()
219
+ const fn1 = vi.fn()
220
+ const fn2 = vi.fn()
221
+ store.subscribe(fn1)
222
+ store.subscribe(fn2)
223
+ store.notify()
224
+ expect(fn1).toHaveBeenCalledOnce()
225
+ expect(fn2).toHaveBeenCalledOnce()
226
+ })
227
+
228
+ it('unsubscribe removes the listener', () => {
229
+ const store = new MinimalStore()
230
+ const fn = vi.fn()
231
+ const unsub = store.subscribe(fn)
232
+ unsub()
233
+ store.notify()
234
+ expect(fn).not.toHaveBeenCalled()
235
+ })
236
+
237
+ it('multiple unsubscribes are idempotent', () => {
238
+ const store = new MinimalStore()
239
+ const fn = vi.fn()
240
+ const unsub = store.subscribe(fn)
241
+ unsub()
242
+ unsub() // second call should be harmless
243
+ store.notify()
244
+ expect(fn).not.toHaveBeenCalled()
245
+ })
246
+ })
247
+
248
+ // ---------------------------------------------------------------------------
249
+ // 6. isRetryableError shared helper
250
+ // ---------------------------------------------------------------------------
251
+
252
+ describe('isRetryableError', () => {
253
+
254
+ it('identifies rate limit errors as transient', () => {
255
+ expect(isRetryableError({ message: 'Error 429: rate limit exceeded' })).toBe(true)
256
+ expect(isRetryableError({ message: 'rate limit exceeded' })).toBe(true)
257
+ })
258
+
259
+ it('identifies server errors as transient', () => {
260
+ expect(isRetryableError({ message: 'Internal server error 500' })).toBe(true)
261
+ expect(isRetryableError({ message: 'Bad gateway 502' })).toBe(true)
262
+ expect(isRetryableError({ message: 'Service unavailable 503' })).toBe(true)
263
+ })
264
+
265
+ it('identifies network errors as transient', () => {
266
+ expect(isRetryableError({ message: 'fetch failed' })).toBe(true)
267
+ expect(isRetryableError({ message: 'ECONNREFUSED' })).toBe(true)
268
+ expect(isRetryableError({ message: 'ECONNRESET' })).toBe(true)
269
+ })
270
+
271
+ it('identifies timeout errors as transient', () => {
272
+ expect(isRetryableError({ message: 'Request timed out' })).toBe(true)
273
+ })
274
+
275
+ it('identifies overloaded errors as transient', () => {
276
+ expect(isRetryableError({ message: 'Server overloaded' })).toBe(true)
277
+ expect(isRetryableError({ message: 'At capacity' })).toBe(true)
278
+ })
279
+
280
+ it('does NOT treat auth errors as transient', () => {
281
+ expect(isRetryableError({ message: 'Invalid API key (401)' })).toBe(false)
282
+ expect(isRetryableError({ message: 'Forbidden (403)' })).toBe(false)
283
+ })
284
+
285
+ it('does NOT treat model-not-found as transient', () => {
286
+ expect(isRetryableError({ message: 'Model does not exist' })).toBe(false)
287
+ })
288
+
289
+ it('handles non-Error objects gracefully', () => {
290
+ expect(isRetryableError('timeout')).toBe(true)
291
+ expect(isRetryableError('something unknown')).toBe(false)
292
+ })
293
+ })
moav2/src/__tests__/session-store-waiting.test.ts ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { AgentEvent } from '@mariozechner/pi-agent-core'
3
+ import { hasAssistantResponseStarted } from '../core/services/session-waiting'
4
+
5
+ describe('session store waiting state', () => {
6
+ it('clears waiting as soon as assistant emits message_update', () => {
7
+ const event = {
8
+ type: 'message_update',
9
+ assistantMessageEvent: { type: 'thinking_start' },
10
+ } as unknown as AgentEvent
11
+
12
+ expect(hasAssistantResponseStarted(event)).toBe(true)
13
+ })
14
+
15
+ it('clears waiting when tool execution starts before text', () => {
16
+ const event = { type: 'tool_execution_start' } as unknown as AgentEvent
17
+ expect(hasAssistantResponseStarted(event)).toBe(true)
18
+ })
19
+
20
+ it('does not clear waiting for unrelated events', () => {
21
+ const event = { type: 'message_start' } as unknown as AgentEvent
22
+ expect(hasAssistantResponseStarted(event)).toBe(false)
23
+ })
24
+ })
moav2/src/__tests__/set-system-prompt-tool.test.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createSetSystemPromptTool } from '../core/tools/set-system-prompt-tool'
3
+
4
+ describe('set_system_prompt tool', () => {
5
+ it('applies prompt and returns success', async () => {
6
+ const applySystemPrompt = vi.fn(async () => {})
7
+ const tool = createSetSystemPromptTool({ applySystemPrompt })
8
+
9
+ const result = await tool.execute('t1', { prompt: ' new prompt ' })
10
+
11
+ expect(applySystemPrompt).toHaveBeenCalledWith('new prompt')
12
+ expect((result.content[0] as any).text).toContain('updated')
13
+ expect((result.details as any).ok).toBe(true)
14
+ })
15
+
16
+ it('returns validation error for empty prompt', async () => {
17
+ const applySystemPrompt = vi.fn(async () => {})
18
+ const tool = createSetSystemPromptTool({ applySystemPrompt })
19
+
20
+ const result = await tool.execute('t1', { prompt: ' ' })
21
+
22
+ expect(applySystemPrompt).not.toHaveBeenCalled()
23
+ expect((result.content[0] as any).text).toContain('Error')
24
+ expect((result.details as any).ok).toBe(false)
25
+ })
26
+ })
moav2/src/__tests__/terminal-buffer-capacitor.test.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+
4
+ let platformType: 'browser' | 'capacitor' | 'electron' = 'capacitor'
5
+
6
+ vi.mock('../core/platform', () => ({
7
+ getPlatform: () => ({
8
+ type: platformType,
9
+ }),
10
+ }))
11
+
12
+ import TerminalBuffer from '../ui/components/TerminalBuffer'
13
+
14
+ describe('TerminalBuffer capacitor mode', () => {
15
+ it('renders capacitor terminal instead of unavailable state', () => {
16
+ platformType = 'capacitor'
17
+ render(<TerminalBuffer id="t1" />)
18
+ expect(screen.queryByText(/Terminal requires desktop mode/)).toBeNull()
19
+ expect(screen.getByText(/Mini shell/)).toBeTruthy()
20
+ })
21
+
22
+ it('keeps browser fallback message in plain browser mode', () => {
23
+ platformType = 'browser'
24
+ render(<TerminalBuffer id="t2" />)
25
+ expect(screen.getByText(/Terminal requires desktop mode/)).toBeTruthy()
26
+ })
27
+ })
moav2/src/__tests__/tools.test.ts ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Agent Tools tests — verifies write, read, edit, bash, and search tools
3
+ * in browser mode backed by @zenfs/core (IndexedDB via fake-indexeddb).
4
+ */
5
+ import { describe, it, expect, beforeAll } from 'vitest'
6
+ import 'fake-indexeddb/auto'
7
+ import { setPlatform, getPlatform } from '../core/platform'
8
+ import { createBrowserPlatform } from '../platform/browser'
9
+ import { createReadTool } from '../core/tools/read-tool'
10
+ import { createWriteTool } from '../core/tools/write-tool'
11
+ import { createEditTool } from '../core/tools/edit-tool'
12
+ import { createBashTool } from '../core/tools/bash-tool'
13
+ import { createSearchTool } from '../core/tools/search-tool'
14
+ import { createAllTools } from '../core/tools'
15
+
16
+ /** Helper to extract text from tool result content block */
17
+ function text(result: any, idx = 0): string {
18
+ return (result.content[idx] as any).text
19
+ }
20
+
21
+ describe('Agent Tools (browser mode)', () => {
22
+ beforeAll(async () => {
23
+ const platform = await createBrowserPlatform()
24
+ setPlatform(platform)
25
+ })
26
+
27
+ describe('write + read', () => {
28
+ it('writes and reads a file', async () => {
29
+ const write = createWriteTool()
30
+ const read = createReadTool()
31
+
32
+ const writeResult = await write.execute('t1', { path: '/project/test.txt', content: 'Hello from MOA' })
33
+ expect(text(writeResult)).toContain('Wrote')
34
+ expect(text(writeResult)).toContain('/project/test.txt')
35
+
36
+ const readResult = await read.execute('t2', { path: '/project/test.txt' })
37
+ expect(text(readResult)).toBe('Hello from MOA')
38
+ })
39
+
40
+ it('creates parent directories automatically', async () => {
41
+ const write = createWriteTool()
42
+ const read = createReadTool()
43
+
44
+ await write.execute('t1', { path: '/deep/nested/dir/file.txt', content: 'deep content' })
45
+ const result = await read.execute('t2', { path: '/deep/nested/dir/file.txt' })
46
+ expect(text(result)).toBe('deep content')
47
+ })
48
+
49
+ it('read returns error for non-existent file', async () => {
50
+ const read = createReadTool()
51
+ const result = await read.execute('t1', { path: '/nonexistent/file.txt' })
52
+ expect(text(result)).toContain('Error')
53
+ })
54
+
55
+ it('write overwrites existing file', async () => {
56
+ const write = createWriteTool()
57
+ const read = createReadTool()
58
+
59
+ await write.execute('t1', { path: '/project/overwrite.txt', content: 'first' })
60
+ await write.execute('t2', { path: '/project/overwrite.txt', content: 'second' })
61
+ const result = await read.execute('t3', { path: '/project/overwrite.txt' })
62
+ expect(text(result)).toBe('second')
63
+ })
64
+ })
65
+
66
+ describe('edit', () => {
67
+ it('replaces text in a file', async () => {
68
+ const write = createWriteTool()
69
+ const edit = createEditTool()
70
+ const read = createReadTool()
71
+
72
+ await write.execute('t1', { path: '/project/edit.txt', content: 'foo bar baz' })
73
+ await edit.execute('t2', { path: '/project/edit.txt', old_string: 'bar', new_string: 'QUX' })
74
+ const result = await read.execute('t3', { path: '/project/edit.txt' })
75
+ expect(text(result)).toBe('foo QUX baz')
76
+ })
77
+
78
+ it('returns error when old_string not found', async () => {
79
+ const write = createWriteTool()
80
+ const edit = createEditTool()
81
+
82
+ await write.execute('t1', { path: '/project/edit2.txt', content: 'hello' })
83
+ const result = await edit.execute('t2', { path: '/project/edit2.txt', old_string: 'MISSING', new_string: 'x' })
84
+ expect(text(result)).toContain('not found')
85
+ })
86
+
87
+ it('replaces only first occurrence', async () => {
88
+ const write = createWriteTool()
89
+ const edit = createEditTool()
90
+ const read = createReadTool()
91
+
92
+ await write.execute('t1', { path: '/project/edit3.txt', content: 'aaa bbb aaa' })
93
+ await edit.execute('t2', { path: '/project/edit3.txt', old_string: 'aaa', new_string: 'ZZZ' })
94
+ const result = await read.execute('t3', { path: '/project/edit3.txt' })
95
+ expect(text(result)).toBe('ZZZ bbb aaa')
96
+ })
97
+
98
+ it('returns error for non-existent file', async () => {
99
+ const edit = createEditTool()
100
+ const result = await edit.execute('t1', { path: '/nonexistent/edit.txt', old_string: 'a', new_string: 'b' })
101
+ expect(text(result)).toContain('Error')
102
+ })
103
+ })
104
+
105
+ describe('bash', () => {
106
+ it('returns browser mode message', async () => {
107
+ const bash = createBashTool()
108
+ const result = await bash.execute('t1', { command: 'ls -la' })
109
+ // In browser mode, process.exec returns exitCode=1 and stdout contains "Browser mode"
110
+ // The bash tool formats exitCode!=0 as "stdout:\n...\nstderr:\n..."
111
+ expect(text(result)).toContain('Browser mode')
112
+ })
113
+
114
+ it('includes the attempted command in output', async () => {
115
+ const bash = createBashTool()
116
+ const result = await bash.execute('t1', { command: 'echo hello' })
117
+ expect(text(result)).toContain('echo hello')
118
+ })
119
+ })
120
+
121
+ describe('search', () => {
122
+ it('searches file content in browser mode', async () => {
123
+ const write = createWriteTool()
124
+ const search = createSearchTool()
125
+
126
+ // Create a file with searchable content
127
+ await write.execute('t1', { path: '/searchtest/file.ts', content: 'const hello = "world"' })
128
+ const result = await search.execute('t2', { query: 'hello', path: '/searchtest', type: 'content' })
129
+ expect(text(result)).toContain('hello')
130
+ })
131
+
132
+ it('searches filenames in browser mode', async () => {
133
+ const write = createWriteTool()
134
+ const search = createSearchTool()
135
+
136
+ await write.execute('t1', { path: '/searchtest2/app.tsx', content: 'export default function App() {}' })
137
+ const result = await search.execute('t2', { query: '*.tsx', path: '/searchtest2', type: 'files' })
138
+ expect(text(result)).toContain('app.tsx')
139
+ })
140
+
141
+ it('returns no matches for content not present', async () => {
142
+ const write = createWriteTool()
143
+ const search = createSearchTool()
144
+
145
+ await write.execute('t1', { path: '/searchtest3/data.txt', content: 'alpha beta gamma' })
146
+ const result = await search.execute('t2', { query: 'zzzzz', path: '/searchtest3', type: 'content' })
147
+ expect(text(result)).toContain('No matches')
148
+ })
149
+
150
+ it('returns no matches for filenames not present', async () => {
151
+ const write = createWriteTool()
152
+ const search = createSearchTool()
153
+
154
+ await write.execute('t1', { path: '/searchtest4/index.js', content: 'x' })
155
+ const result = await search.execute('t2', { query: '*.py', path: '/searchtest4', type: 'files' })
156
+ expect(text(result)).toContain('No matches')
157
+ })
158
+
159
+ it('content search includes line numbers', async () => {
160
+ const write = createWriteTool()
161
+ const search = createSearchTool()
162
+
163
+ await write.execute('t1', {
164
+ path: '/searchtest5/multi.txt',
165
+ content: 'line one\nline two target\nline three',
166
+ })
167
+ const result = await search.execute('t2', { query: 'target', path: '/searchtest5', type: 'content' })
168
+ // Browser file search returns "path:linenum: content"
169
+ expect(text(result)).toContain(':2:')
170
+ expect(text(result)).toContain('target')
171
+ })
172
+
173
+ it('searches with default path when path not specified', async () => {
174
+ const write = createWriteTool()
175
+ const search = createSearchTool()
176
+
177
+ // Write to root (browser cwd is /)
178
+ await write.execute('t1', { path: '/rootsearch.txt', content: 'findme' })
179
+ const result = await search.execute('t2', { query: 'findme' })
180
+ expect(text(result)).toContain('findme')
181
+ })
182
+ })
183
+
184
+ describe('tool metadata', () => {
185
+ it('tools have name, label, description, parameters', () => {
186
+ const tools = [
187
+ createReadTool(),
188
+ createWriteTool(),
189
+ createEditTool(),
190
+ createBashTool(),
191
+ createSearchTool(),
192
+ ]
193
+
194
+ for (const tool of tools) {
195
+ expect(tool.name).toBeTruthy()
196
+ expect(tool.label).toBeTruthy()
197
+ expect(tool.description).toBeTruthy()
198
+ expect(tool.parameters).toBeDefined()
199
+ expect(typeof tool.execute).toBe('function')
200
+ }
201
+ })
202
+
203
+ it('tools have distinct names', () => {
204
+ const tools = [
205
+ createReadTool(),
206
+ createWriteTool(),
207
+ createEditTool(),
208
+ createBashTool(),
209
+ createSearchTool(),
210
+ ]
211
+ const names = tools.map(t => t.name)
212
+ expect(new Set(names).size).toBe(names.length)
213
+ })
214
+
215
+ it('registers web_fetch in default tool set', () => {
216
+ const tools = createAllTools()
217
+ expect(tools.some(t => t.name === 'web_fetch')).toBe(true)
218
+ })
219
+ })
220
+ })
moav2/src/__tests__/window-constraints.test.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, it, expect } from 'vitest'
2
+ import { WINDOW_CONSTRAINTS } from '../electron/window-constraints'
3
+
4
+ describe('WINDOW_CONSTRAINTS', () => {
5
+ it('enforces a minimum width to prevent resize thrashing', () => {
6
+ expect(WINDOW_CONSTRAINTS.minWidth).toBeGreaterThanOrEqual(480)
7
+ })
8
+
9
+ it('enforces a minimum height to prevent resize thrashing', () => {
10
+ expect(WINDOW_CONSTRAINTS.minHeight).toBeGreaterThanOrEqual(360)
11
+ })
12
+
13
+ it('default size is larger than minimum', () => {
14
+ expect(WINDOW_CONSTRAINTS.width).toBeGreaterThan(WINDOW_CONSTRAINTS.minWidth)
15
+ expect(WINDOW_CONSTRAINTS.height).toBeGreaterThan(WINDOW_CONSTRAINTS.minHeight)
16
+ })
17
+ })
moav2/src/cli/db-reader.ts ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * HistoryDbReader — SQLite-backed reader for MOA chat history.
3
+ *
4
+ * Uses Node's built-in SQLite (`node:sqlite`) through an async API.
5
+ * This is a read-only tool for CLI access; the Electron app writes to this database.
6
+ *
7
+ * The reader also provides insert methods for testing purposes.
8
+ */
9
+
10
+ type StatementLike = {
11
+ all(...params: any[]): any[]
12
+ get(...params: any[]): any
13
+ run(...params: any[]): any
14
+ }
15
+
16
+ type DbLike = {
17
+ exec(sql: string): void
18
+ pragma?(sql: string): void
19
+ prepare(sql: string): StatementLike
20
+ close(): void
21
+ }
22
+
23
+ async function openDatabase(dbPath: string): Promise<DbLike> {
24
+ try {
25
+ const nodeSqlite = await import('node:sqlite')
26
+ const db = new nodeSqlite.DatabaseSync(dbPath)
27
+ return {
28
+ exec(sql: string) {
29
+ db.exec(sql)
30
+ },
31
+ pragma(sql: string) {
32
+ db.exec(`PRAGMA ${sql}`)
33
+ },
34
+ prepare(sql: string) {
35
+ return db.prepare(sql)
36
+ },
37
+ close() {
38
+ db.close()
39
+ },
40
+ }
41
+ } catch (err: any) {
42
+ throw new Error(`Could not initialize SQLite for ${dbPath}: ${err?.message ?? String(err)}`)
43
+ }
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Types
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface CliSession {
51
+ id: string
52
+ title: string
53
+ model: string
54
+ createdAt: number
55
+ updatedAt: number
56
+ }
57
+
58
+ export interface CliMessage {
59
+ id: string
60
+ sessionId: string
61
+ role: 'user' | 'assistant' | 'system'
62
+ content: string
63
+ blocks?: string // JSON-serialized MessageBlock[]
64
+ partial: number // 0 or 1
65
+ createdAt: number
66
+ }
67
+
68
+ export interface SearchResult extends CliMessage {
69
+ sessionTitle: string
70
+ }
71
+
72
+ export interface GetMessagesOptions {
73
+ role?: string
74
+ limit?: number
75
+ }
76
+
77
+ export interface SearchOptions {
78
+ sessionId?: string
79
+ limit?: number
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Schema
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const SCHEMA_SQL = `
87
+ CREATE TABLE IF NOT EXISTS sessions (
88
+ id TEXT PRIMARY KEY,
89
+ title TEXT NOT NULL,
90
+ model TEXT NOT NULL,
91
+ createdAt INTEGER NOT NULL,
92
+ updatedAt INTEGER NOT NULL
93
+ );
94
+
95
+ CREATE TABLE IF NOT EXISTS messages (
96
+ id TEXT PRIMARY KEY,
97
+ sessionId TEXT NOT NULL,
98
+ role TEXT NOT NULL,
99
+ content TEXT NOT NULL DEFAULT '',
100
+ blocks TEXT,
101
+ partial INTEGER DEFAULT 0,
102
+ createdAt INTEGER NOT NULL
103
+ );
104
+
105
+ CREATE INDEX IF NOT EXISTS idx_messages_sessionId ON messages(sessionId);
106
+ CREATE INDEX IF NOT EXISTS idx_messages_createdAt ON messages(createdAt);
107
+ CREATE INDEX IF NOT EXISTS idx_sessions_updatedAt ON sessions(updatedAt);
108
+ `
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Reader
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export class HistoryDbReader {
115
+ private db: DbLike
116
+
117
+ private constructor(db: DbLike) {
118
+ this.db = db
119
+ }
120
+
121
+ static async create(dbPath: string): Promise<HistoryDbReader> {
122
+ const db = await openDatabase(dbPath)
123
+ const reader = new HistoryDbReader(db)
124
+ reader.init()
125
+ return reader
126
+ }
127
+
128
+ private init(): void {
129
+ this.db.pragma('journal_mode = WAL')
130
+ this.db.pragma('foreign_keys = OFF')
131
+ this.db.exec(SCHEMA_SQL)
132
+ }
133
+
134
+ async close(): Promise<void> {
135
+ this.db.close()
136
+ }
137
+
138
+ // --- Query methods ---
139
+
140
+ async listSessions(limit?: number): Promise<CliSession[]> {
141
+ let sql = 'SELECT * FROM sessions ORDER BY updatedAt DESC'
142
+ if (limit !== undefined && limit > 0) {
143
+ sql += ` LIMIT ${limit}`
144
+ }
145
+ return this.db.prepare(sql).all() as CliSession[]
146
+ }
147
+
148
+ async getSession(id: string): Promise<CliSession | null> {
149
+ const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as CliSession | undefined
150
+ return row ?? null
151
+ }
152
+
153
+ async getMessages(sessionId: string, opts?: GetMessagesOptions): Promise<CliMessage[]> {
154
+ const conditions = ['sessionId = ?', 'partial = 0']
155
+ const params: any[] = [sessionId]
156
+
157
+ if (opts?.role) {
158
+ conditions.push('role = ?')
159
+ params.push(opts.role)
160
+ }
161
+
162
+ let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY createdAt ASC`
163
+
164
+ if (opts?.limit !== undefined && opts.limit > 0) {
165
+ // To get the LAST N messages, we use a subquery
166
+ sql = `SELECT * FROM (
167
+ SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY createdAt DESC LIMIT ${opts.limit}
168
+ ) sub ORDER BY createdAt ASC`
169
+ }
170
+
171
+ return this.db.prepare(sql).all(...params) as CliMessage[]
172
+ }
173
+
174
+ async searchMessages(query: string, opts?: SearchOptions): Promise<SearchResult[]> {
175
+ if (!query.trim()) return []
176
+
177
+ const conditions = ['m.partial = 0', 'm.content LIKE ?']
178
+ const params: any[] = [`%${query}%`]
179
+
180
+ if (opts?.sessionId) {
181
+ conditions.push('m.sessionId = ?')
182
+ params.push(opts.sessionId)
183
+ }
184
+
185
+ const limit = opts?.limit ?? 20
186
+
187
+ const sql = `
188
+ SELECT m.*, s.title as sessionTitle
189
+ FROM messages m
190
+ JOIN sessions s ON s.id = m.sessionId
191
+ WHERE ${conditions.join(' AND ')}
192
+ ORDER BY m.createdAt DESC
193
+ LIMIT ${limit}
194
+ `
195
+
196
+ return this.db.prepare(sql).all(...params) as SearchResult[]
197
+ }
198
+
199
+ // --- Insert methods (for testing) ---
200
+
201
+ async insertSession(session: CliSession): Promise<void> {
202
+ this.db.prepare(
203
+ 'INSERT OR REPLACE INTO sessions (id, title, model, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)'
204
+ ).run(session.id, session.title, session.model, session.createdAt, session.updatedAt)
205
+ }
206
+
207
+ async insertMessage(message: CliMessage): Promise<void> {
208
+ this.db.prepare(
209
+ 'INSERT OR REPLACE INTO messages (id, sessionId, role, content, blocks, partial, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)'
210
+ ).run(
211
+ message.id,
212
+ message.sessionId,
213
+ message.role,
214
+ message.content,
215
+ message.blocks ?? null,
216
+ message.partial,
217
+ message.createdAt
218
+ )
219
+ }
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Default database path resolution
224
+ // ---------------------------------------------------------------------------
225
+
226
+ export function getDefaultDbPath(): string {
227
+ const os = require('os')
228
+ const path = require('path')
229
+
230
+ // Check environment variable override
231
+ if (process.env.MOA_DB_PATH) {
232
+ return process.env.MOA_DB_PATH
233
+ }
234
+
235
+ return path.join(os.homedir(), '.moa', 'chat-history.db')
236
+ }
moav2/src/cli/history.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * History command implementations.
3
+ *
4
+ * Pure functions that take a HistoryDbReader and options, returning formatted strings.
5
+ * No direct stdout writes — the CLI entry point handles I/O.
6
+ */
7
+
8
+ import { HistoryDbReader, type CliSession, type CliMessage, type SearchResult } from './db-reader'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Shared helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function formatTimestamp(ms: number): string {
15
+ const d = new Date(ms)
16
+ return d.toISOString().slice(0, 16).replace('T', ' ')
17
+ }
18
+
19
+ function formatTime(ms: number): string {
20
+ const d = new Date(ms)
21
+ return d.toISOString().slice(11, 16)
22
+ }
23
+
24
+ function truncate(text: string, maxLen: number): string {
25
+ if (text.length <= maxLen) return text
26
+ return text.substring(0, maxLen) + '...'
27
+ }
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // List Sessions
31
+ // ---------------------------------------------------------------------------
32
+
33
+ export interface ListOptions {
34
+ limit?: number
35
+ json?: boolean
36
+ }
37
+
38
+ export async function formatSessionList(reader: HistoryDbReader, opts: ListOptions): Promise<string> {
39
+ const sessions = await reader.listSessions(opts.limit)
40
+
41
+ if (opts.json) {
42
+ return JSON.stringify(sessions, null, 2)
43
+ }
44
+
45
+ if (sessions.length === 0) {
46
+ return 'No sessions found.'
47
+ }
48
+
49
+ const lines = sessions.map((s: CliSession) => {
50
+ const created = formatTimestamp(s.createdAt)
51
+ const updated = formatTimestamp(s.updatedAt)
52
+ return ` [${s.id}] "${s.title}"\n Model: ${s.model} Created: ${created} Updated: ${updated}`
53
+ })
54
+
55
+ return `${sessions.length} session(s):\n\n${lines.join('\n\n')}`
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // View Session
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export interface ViewOptions {
63
+ limit?: number
64
+ role?: string
65
+ json?: boolean
66
+ }
67
+
68
+ export async function formatSessionView(reader: HistoryDbReader, sessionId: string, opts: ViewOptions): Promise<string> {
69
+ const session = await reader.getSession(sessionId)
70
+ if (!session) {
71
+ return `Session not found: ${sessionId}`
72
+ }
73
+
74
+ const messages = await reader.getMessages(sessionId, {
75
+ role: opts.role,
76
+ limit: opts.limit,
77
+ })
78
+
79
+ if (opts.json) {
80
+ return JSON.stringify({ session, messages }, null, 2)
81
+ }
82
+
83
+ if (messages.length === 0) {
84
+ return `Session: "${session.title}" (${session.id})\nModel: ${session.model}\nCreated: ${formatTimestamp(session.createdAt)}\n\nNo messages in this session.`
85
+ }
86
+
87
+ const header = `Session: "${session.title}" (${session.id})\nModel: ${session.model}\nCreated: ${formatTimestamp(session.createdAt)}\n\n--- Messages (${messages.length}) ---`
88
+
89
+ const msgLines = messages.map((m: CliMessage) => {
90
+ const time = formatTime(m.createdAt)
91
+ return `\n[${time}] ${m.role}:\n ${truncate(m.content, 500)}`
92
+ })
93
+
94
+ return header + msgLines.join('\n')
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Search
99
+ // ---------------------------------------------------------------------------
100
+
101
+ export interface SearchCommandOptions {
102
+ limit?: number
103
+ sessionId?: string
104
+ json?: boolean
105
+ }
106
+
107
+ export async function formatSearchResults(reader: HistoryDbReader, query: string, opts: SearchCommandOptions): Promise<string> {
108
+ const results = await reader.searchMessages(query, {
109
+ sessionId: opts.sessionId,
110
+ limit: opts.limit,
111
+ })
112
+
113
+ if (opts.json) {
114
+ return JSON.stringify(results, null, 2)
115
+ }
116
+
117
+ if (results.length === 0) {
118
+ return `No matches found for "${query}".`
119
+ }
120
+
121
+ const lines = results.map((r: SearchResult) => {
122
+ const time = formatTime(r.createdAt)
123
+ return ` [${r.sessionId}] "${r.sessionTitle}" | [${time}] ${r.role}:\n ${truncate(r.content, 300)}`
124
+ })
125
+
126
+ return `Search results for "${query}" (${results.length} match${results.length === 1 ? '' : 'es'}):\n\n${lines.join('\n\n')}`
127
+ }
moav2/src/cli/index.ts ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * MOA History CLI — Access chat history from the terminal.
4
+ *
5
+ * Usage:
6
+ * moa history list [--limit N] [--json]
7
+ * moa history view <session-id> [--limit N] [--role <role>] [--json]
8
+ * moa history search <query> [--limit N] [--session <id>] [--json]
9
+ *
10
+ * Options:
11
+ * --db <path> Override database path (default: ~/.moa/chat-history.db)
12
+ * --help Show help
13
+ */
14
+
15
+ import { HistoryDbReader, getDefaultDbPath } from './db-reader'
16
+ import { formatSessionList, formatSessionView, formatSearchResults } from './history'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Argument parsing
20
+ // ---------------------------------------------------------------------------
21
+
22
+ interface ParsedArgs {
23
+ command: string
24
+ positional: string[]
25
+ flags: Record<string, string | boolean>
26
+ }
27
+
28
+ function parseArgs(argv: string[]): ParsedArgs {
29
+ // Skip node and script path
30
+ const args = argv.slice(2)
31
+ const command = args[0] || ''
32
+ const positional: string[] = []
33
+ const flags: Record<string, string | boolean> = {}
34
+
35
+ let i = 1
36
+ while (i < args.length) {
37
+ const arg = args[i]
38
+ if (arg.startsWith('--')) {
39
+ const key = arg.slice(2)
40
+ // Check if next arg is a value (not a flag)
41
+ if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
42
+ flags[key] = args[i + 1]
43
+ i += 2
44
+ } else {
45
+ flags[key] = true
46
+ i++
47
+ }
48
+ } else {
49
+ positional.push(arg)
50
+ i++
51
+ }
52
+ }
53
+
54
+ return { command, positional, flags }
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Help text
59
+ // ---------------------------------------------------------------------------
60
+
61
+ const HELP_TEXT = `
62
+ MOA History CLI — Access chat history from the terminal.
63
+
64
+ Usage:
65
+ npx tsx src/cli/index.ts list [--limit N] [--json]
66
+ npx tsx src/cli/index.ts view <session-id> [--limit N] [--role <role>] [--json]
67
+ npx tsx src/cli/index.ts search <query> [--limit N] [--session <id>] [--json]
68
+
69
+ Commands:
70
+ list List all chat sessions
71
+ view <session-id> View messages in a session
72
+ search <query> Search across all messages
73
+
74
+ Options:
75
+ --limit N Limit number of results
76
+ --role <role> Filter messages by role (user, assistant, system)
77
+ --session <id> Restrict search to a specific session
78
+ --json Output as JSON
79
+ --db <path> Override database path (default: ~/.moa/chat-history.db)
80
+ --help Show this help
81
+
82
+ Environment:
83
+ MOA_DB_PATH Override database path
84
+ `.trim()
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Main
88
+ // ---------------------------------------------------------------------------
89
+
90
+ async function main() {
91
+ const parsed = parseArgs(process.argv)
92
+
93
+ if (parsed.flags.help || parsed.command === 'help' || !parsed.command) {
94
+ console.log(HELP_TEXT)
95
+ process.exit(parsed.command ? 0 : 2)
96
+ }
97
+
98
+ // Resolve database path
99
+ const dbPath = typeof parsed.flags.db === 'string' ? parsed.flags.db : getDefaultDbPath()
100
+
101
+ let reader: HistoryDbReader
102
+ try {
103
+ reader = await HistoryDbReader.create(dbPath)
104
+ } catch (err: any) {
105
+ console.error(`Error: Could not open database at ${dbPath}`)
106
+ console.error(err.message)
107
+ console.error('\nMake sure the MOA database exists. Run the Electron app first to create it,')
108
+ console.error('or specify a different path with --db <path> or MOA_DB_PATH env variable.')
109
+ process.exit(1)
110
+ }
111
+
112
+ try {
113
+ const json = !!parsed.flags.json
114
+ const limit = typeof parsed.flags.limit === 'string' ? parseInt(parsed.flags.limit, 10) : undefined
115
+
116
+ switch (parsed.command) {
117
+ case 'list': {
118
+ const output = await formatSessionList(reader, { limit, json })
119
+ console.log(output)
120
+ break
121
+ }
122
+
123
+ case 'view': {
124
+ const sessionId = parsed.positional[0]
125
+ if (!sessionId) {
126
+ console.error('Error: session ID is required for the view command.')
127
+ console.error('Usage: moa history view <session-id>')
128
+ process.exit(2)
129
+ }
130
+ const role = typeof parsed.flags.role === 'string' ? parsed.flags.role : undefined
131
+ const output = await formatSessionView(reader, sessionId, { limit, role, json })
132
+
133
+ if (output.startsWith('Session not found:')) {
134
+ console.error(output)
135
+ process.exit(3)
136
+ }
137
+
138
+ console.log(output)
139
+ break
140
+ }
141
+
142
+ case 'search': {
143
+ const query = parsed.positional[0]
144
+ if (!query) {
145
+ console.error('Error: search query is required.')
146
+ console.error('Usage: moa history search <query>')
147
+ process.exit(2)
148
+ }
149
+ const sessionId = typeof parsed.flags.session === 'string' ? parsed.flags.session : undefined
150
+ const output = await formatSearchResults(reader, query, { limit, sessionId, json })
151
+ console.log(output)
152
+ break
153
+ }
154
+
155
+ default:
156
+ console.error(`Unknown command: ${parsed.command}`)
157
+ console.error('Valid commands: list, view, search')
158
+ console.error('Run with --help for usage information.')
159
+ process.exit(2)
160
+ }
161
+ } finally {
162
+ await reader.close()
163
+ }
164
+ }
165
+
166
+ main().catch((err: any) => {
167
+ console.error(err?.message ?? String(err))
168
+ process.exit(1)
169
+ })
moav2/src/core/platform/index.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Platform } from './types'
2
+
3
+ let _platform: Platform | null = null
4
+
5
+ export function setPlatform(p: Platform): void {
6
+ _platform = p
7
+ }
8
+
9
+ export function getPlatform(): Platform {
10
+ if (!_platform) {
11
+ throw new Error('Platform not initialized. Call setPlatform() before using any platform APIs.')
12
+ }
13
+ return _platform
14
+ }
15
+
16
+ export type { Platform, PlatformFs, PlatformPath, PlatformProcess, PlatformDatabase, PlatformStatement, PlatformSqlite, PlatformShell } from './types'
moav2/src/core/platform/types.ts ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Platform abstraction — every Node.js dependency goes through this interface.
2
+ // Browser, Electron, and Capacitor each provide their own implementation.
3
+
4
+ export interface PlatformFs {
5
+ readFile(path: string, encoding: 'utf-8'): Promise<string>
6
+ readFileSync(path: string, encoding: 'utf-8'): string
7
+ writeFile(path: string, content: string): Promise<void>
8
+ writeFileSync(path: string, content: string): void
9
+ existsSync(path: string): boolean
10
+ mkdirSync(path: string, opts?: { recursive: boolean }): void
11
+ readdirSync(path: string): string[]
12
+ statSync(path: string): { isFile(): boolean; isDirectory(): boolean; size: number }
13
+ unlinkSync(path: string): void
14
+ }
15
+
16
+ export interface PlatformPath {
17
+ join(...parts: string[]): string
18
+ dirname(p: string): string
19
+ resolve(...parts: string[]): string
20
+ basename(p: string, ext?: string): string
21
+ extname(p: string): string
22
+ sep: string
23
+ }
24
+
25
+ export interface PlatformProcess {
26
+ exec(command: string, opts?: { timeout?: number; maxBuffer?: number; cwd?: string }):
27
+ Promise<{ stdout: string; stderr: string; exitCode: number }>
28
+ execSync(command: string, opts?: { encoding?: string; timeout?: number; maxBuffer?: number; cwd?: string }): string
29
+ cwd(): string
30
+ env: Record<string, string | undefined>
31
+ homedir(): string
32
+ }
33
+
34
+ export interface PlatformDatabase {
35
+ exec(sql: string): Promise<void>
36
+ prepare(sql: string): PlatformStatement
37
+ close(): Promise<void>
38
+ }
39
+
40
+ export interface PlatformStatement {
41
+ run(...params: any[]): Promise<any>
42
+ get(...params: any[]): Promise<any>
43
+ all(...params: any[]): Promise<any[]>
44
+ }
45
+
46
+ export interface PlatformSqlite {
47
+ open(name: string): Promise<PlatformDatabase>
48
+ }
49
+
50
+ export interface PlatformShell {
51
+ openExternal(url: string): void
52
+ }
53
+
54
+ export interface Platform {
55
+ fs: PlatformFs
56
+ path: PlatformPath
57
+ process: PlatformProcess
58
+ sqlite: PlatformSqlite
59
+ shell: PlatformShell
60
+ type: 'browser' | 'electron' | 'capacitor'
61
+ }
moav2/src/core/services/action-logger.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Action Logger — singleton wrapper around EventStore for convenient action logging
2
+ // Initialize with the platform's EventStore instance at boot.
3
+ // Provides typed convenience methods for logging different action categories.
4
+ // All logging is fire-and-forget: failures are silently caught so they never
5
+ // break the calling code.
6
+
7
+ import { EventStore } from './event-store'
8
+
9
+ let store: EventStore | null = null
10
+
11
+ /** Wire up the action logger with an initialised EventStore. Call once at boot. */
12
+ export function initActionLogger(eventStore: EventStore): void {
13
+ store = eventStore
14
+ }
15
+
16
+ /** Get the underlying EventStore (or null if not yet initialised). */
17
+ export function getActionLogger(): EventStore | null {
18
+ return store
19
+ }
20
+
21
+ /** Fire-and-forget: append an action event. Never throws. */
22
+ export function logAction(
23
+ type: string,
24
+ payload: Record<string, any>,
25
+ opts?: {
26
+ actor?: string
27
+ sessionId?: string
28
+ causationId?: string
29
+ correlationId?: string
30
+ },
31
+ ): void {
32
+ if (!store) return
33
+ store
34
+ .append({
35
+ type,
36
+ payload,
37
+ actor: opts?.actor ?? 'system',
38
+ sessionId: opts?.sessionId,
39
+ causationId: opts?.causationId,
40
+ correlationId: opts?.correlationId,
41
+ })
42
+ .catch(() => {
43
+ // Logging must never break the caller — swallow errors silently.
44
+ })
45
+ }
moav2/src/core/services/agent-service.ts ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Agent } from '@mariozechner/pi-agent-core'
2
+ import type { AgentEvent, AgentTool, AgentMessage } from '@mariozechner/pi-agent-core'
3
+ import type { Model } from '@mariozechner/pi-ai'
4
+ import { createAllTools } from './tools/index'
5
+
6
+ export interface AgentConfig {
7
+ model: Model<any>
8
+ tools?: AgentTool<any, any>[]
9
+ systemPrompt?: string
10
+ getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined
11
+ }
12
+
13
+ export interface StreamingBlock {
14
+ id: string
15
+ type: 'text' | 'tool' | 'thinking'
16
+ content?: string
17
+ toolCallId?: string
18
+ toolName?: string
19
+ args?: Record<string, any>
20
+ status?: 'running' | 'completed' | 'error'
21
+ result?: string
22
+ }
23
+
24
+ let _blockCounter = 0
25
+ function nextBlockId() {
26
+ return `blk-${++_blockCounter}-${Date.now()}`
27
+ }
28
+
29
+ export class AgentService {
30
+ private agents: Map<string, Agent> = new Map()
31
+ private subscribers: Map<string, Set<(event: AgentEvent) => void>> = new Map()
32
+ private streamingBuffers: Set<string> = new Set()
33
+ // Accumulates streaming blocks so UI can recover on remount
34
+ private streamingBlocks: Map<string, StreamingBlock[]> = new Map()
35
+ // Tracks partial DB message IDs — survives component remounts
36
+ private partialMsgIds: Map<string, string> = new Map()
37
+
38
+ createAgent(bufferId: string, config: AgentConfig): Agent {
39
+ if (this.agents.has(bufferId)) {
40
+ return this.agents.get(bufferId)!
41
+ }
42
+
43
+ const agent = new Agent({
44
+ getApiKey: config.getApiKey,
45
+ })
46
+
47
+ agent.setModel(config.model)
48
+ // Web fetch support matrix:
49
+ // - Anthropic (API key/OAuth): supported via tool use.
50
+ // - OpenAI (API key/OAuth): supported via function/tool calling.
51
+ // - Vertex AI / Vertex Express: supported via function/tool calling.
52
+ // - Custom OpenAI-compatible providers: supported when they implement tool calling.
53
+ agent.setTools(config.tools ?? createAllTools())
54
+
55
+ if (config.systemPrompt) {
56
+ agent.setSystemPrompt(config.systemPrompt)
57
+ }
58
+
59
+ agent.subscribe((event: AgentEvent) => {
60
+ if (event.type === 'agent_start') {
61
+ this.streamingBuffers.add(bufferId)
62
+ this.streamingBlocks.set(bufferId, [])
63
+ }
64
+
65
+ // Accumulate blocks in the service so UI can recover on remount
66
+ this.accumulateBlock(bufferId, event)
67
+
68
+ // Notify subscribers BEFORE clearing state on agent_end
69
+ // so they can read the final blocks
70
+ const subs = this.subscribers.get(bufferId)
71
+ if (subs) {
72
+ for (const fn of subs) {
73
+ fn(event)
74
+ }
75
+ }
76
+
77
+ // Clean up streaming state AFTER notifying subscribers
78
+ if (event.type === 'agent_end') {
79
+ this.streamingBuffers.delete(bufferId)
80
+ this.streamingBlocks.delete(bufferId)
81
+ }
82
+ })
83
+
84
+ this.agents.set(bufferId, agent)
85
+ return agent
86
+ }
87
+
88
+ getAgent(bufferId: string): Agent | undefined {
89
+ return this.agents.get(bufferId)
90
+ }
91
+
92
+ /**
93
+ * Hot-swap the model on an existing agent without destroying it.
94
+ * Preserves conversation history, tools, subscriptions, and streaming state.
95
+ * Also updates the getApiKey callback if the auth method changed.
96
+ */
97
+ updateModel(bufferId: string, model: Model<any>, getApiKey?: (provider: string) => Promise<string | undefined> | string | undefined): void {
98
+ const agent = this.agents.get(bufferId)
99
+ if (!agent) {
100
+ throw new Error(`No agent for buffer ${bufferId} — cannot update model`)
101
+ }
102
+ agent.setModel(model)
103
+ if (getApiKey) {
104
+ agent.getApiKey = getApiKey
105
+ }
106
+ }
107
+
108
+ destroyAgent(bufferId: string): void {
109
+ const agent = this.agents.get(bufferId)
110
+ if (agent) {
111
+ agent.abort()
112
+ this.agents.delete(bufferId)
113
+ this.subscribers.delete(bufferId)
114
+ this.streamingBlocks.delete(bufferId)
115
+ this.streamingBuffers.delete(bufferId)
116
+ this.partialMsgIds.delete(bufferId)
117
+ }
118
+ }
119
+
120
+ /** Clean up agents for sessions that no longer exist */
121
+ pruneAgents(activeSessionIds: Set<string>): void {
122
+ for (const bufferId of this.agents.keys()) {
123
+ if (!activeSessionIds.has(bufferId) && !this.streamingBuffers.has(bufferId)) {
124
+ this.destroyAgent(bufferId)
125
+ }
126
+ }
127
+ }
128
+
129
+ async prompt(bufferId: string, message: string, images?: { type: 'image'; data: string; mimeType: string }[]): Promise<void> {
130
+ const agent = this.agents.get(bufferId)
131
+ if (!agent) {
132
+ throw new Error(`No agent for buffer ${bufferId}`)
133
+ }
134
+ await agent.prompt(message, images)
135
+ }
136
+
137
+ steer(bufferId: string, message: string): void {
138
+ const agent = this.agents.get(bufferId)
139
+ if (!agent) {
140
+ throw new Error(`No agent for buffer ${bufferId}`)
141
+ }
142
+
143
+ // Snapshot current streaming blocks before steering
144
+ // This allows UI to split the output at the interruption point
145
+ const currentBlocks = this.streamingBlocks.get(bufferId)
146
+ if (currentBlocks && currentBlocks.length > 0) {
147
+ // Emit a custom event so UI can capture the "before" blocks
148
+ const subs = this.subscribers.get(bufferId)
149
+ if (subs) {
150
+ for (const fn of subs) {
151
+ fn({ type: 'steer_interrupt', blocks: [...currentBlocks] } as any)
152
+ }
153
+ }
154
+ // Clear streaming blocks - new content after steer will be fresh
155
+ this.streamingBlocks.set(bufferId, [])
156
+ }
157
+
158
+ agent.steer({
159
+ role: 'user',
160
+ content: [{ type: 'text', text: message }],
161
+ timestamp: Date.now(),
162
+ })
163
+ }
164
+
165
+ isStreaming(bufferId: string): boolean {
166
+ return this.streamingBuffers.has(bufferId)
167
+ }
168
+
169
+ // Returns a snapshot of in-progress streaming blocks for a buffer.
170
+ // Used by UI to recover state after remount.
171
+ getStreamingBlocks(bufferId: string): StreamingBlock[] {
172
+ return this.streamingBlocks.get(bufferId) || []
173
+ }
174
+
175
+ // Partial message ID tracking — stored here so it survives component remounts
176
+ setPartialMsgId(bufferId: string, id: string): void {
177
+ this.partialMsgIds.set(bufferId, id)
178
+ }
179
+ getPartialMsgId(bufferId: string): string | null {
180
+ return this.partialMsgIds.get(bufferId) || null
181
+ }
182
+ clearPartialMsgId(bufferId: string): void {
183
+ this.partialMsgIds.delete(bufferId)
184
+ }
185
+
186
+ // Hydrate agent with prior conversation from DB messages.
187
+ // Only injects if agent has empty history (fresh creation, not HMR).
188
+ hydrateFromMessages(bufferId: string, dbMessages: { role: string; content: string }[]): void {
189
+ const agent = this.agents.get(bufferId)
190
+ if (!agent) return
191
+ // Don't overwrite existing history (agent survived HMR and already has context)
192
+ // Only return if we have actual user/assistant messages.
193
+ // If we only have a system message (from initialization), we should still hydrate.
194
+ const hasConversationHistory = agent.state.messages.some(m => (m.role as string) !== 'system')
195
+ if (hasConversationHistory) return
196
+
197
+ const agentMessages: AgentMessage[] = []
198
+ for (const m of dbMessages) {
199
+ if (m.role === 'user') {
200
+ agentMessages.push({
201
+ role: 'user',
202
+ content: m.content,
203
+ timestamp: Date.now(),
204
+ } as AgentMessage)
205
+ } else if (m.role === 'assistant' && m.content) {
206
+ // Inject assistant messages as simplified text so the agent knows its own prior responses.
207
+ // Cast via unknown because we're constructing a minimal AssistantMessage shape.
208
+ agentMessages.push({
209
+ role: 'assistant',
210
+ content: [{ type: 'text', text: m.content }],
211
+ api: 'anthropic',
212
+ provider: 'anthropic',
213
+ model: 'unknown',
214
+ usage: { inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
215
+ stopReason: 'stop',
216
+ timestamp: Date.now(),
217
+ } as unknown as AgentMessage)
218
+ }
219
+ }
220
+ if (agentMessages.length > 0) {
221
+ agent.replaceMessages(agentMessages)
222
+ }
223
+ }
224
+
225
+ private accumulateBlock(bufferId: string, event: AgentEvent): void {
226
+ const blocks = this.streamingBlocks.get(bufferId)
227
+ if (!blocks) return
228
+
229
+ const ae = event as any
230
+ switch (event.type) {
231
+ case 'message_update': {
232
+ const assistantEvent = ae.assistantMessageEvent
233
+ if (!assistantEvent) break
234
+
235
+ if (assistantEvent.type === 'thinking_start') {
236
+ blocks.push({ id: nextBlockId(), type: 'thinking', content: '' })
237
+ break
238
+ }
239
+
240
+ if (assistantEvent.type === 'thinking_delta') {
241
+ const last = blocks[blocks.length - 1]
242
+ if (last && last.type === 'thinking') {
243
+ last.content = (last.content || '') + assistantEvent.delta
244
+ } else {
245
+ blocks.push({ id: nextBlockId(), type: 'thinking', content: assistantEvent.delta })
246
+ }
247
+ break
248
+ }
249
+
250
+ if (assistantEvent.type === 'thinking_end') {
251
+ break
252
+ }
253
+
254
+ if (assistantEvent.type === 'text_delta') {
255
+ const last = blocks[blocks.length - 1]
256
+ if (last && last.type === 'text') {
257
+ last.content = (last.content || '') + assistantEvent.delta
258
+ } else {
259
+ blocks.push({ id: nextBlockId(), type: 'text', content: assistantEvent.delta })
260
+ }
261
+ }
262
+ break
263
+ }
264
+ case 'tool_execution_start':
265
+ blocks.push({
266
+ id: nextBlockId(),
267
+ type: 'tool',
268
+ toolCallId: ae.toolCallId,
269
+ toolName: ae.toolName,
270
+ args: ae.args,
271
+ status: 'running',
272
+ })
273
+ break
274
+ case 'tool_execution_end': {
275
+ const block = blocks.find(b => b.type === 'tool' && b.toolCallId === ae.toolCallId)
276
+ if (block) {
277
+ block.status = ae.isError ? 'error' : 'completed'
278
+ block.result = ae.result?.content?.map((c: any) => c.text || '').join('') || JSON.stringify(ae.result)
279
+ }
280
+ break
281
+ }
282
+ case 'message_end': {
283
+ if (ae.message?.errorMessage || ae.message?.stopReason === 'error') {
284
+ blocks.push({ id: nextBlockId(), type: 'text', content: `\n\n**Error:** ${ae.message.errorMessage || 'Unknown API error'}` })
285
+ }
286
+ break
287
+ }
288
+ }
289
+ }
290
+
291
+ abort(bufferId: string): void {
292
+ const agent = this.agents.get(bufferId)
293
+ if (agent) {
294
+ agent.abort()
295
+ }
296
+ }
297
+
298
+ subscribe(bufferId: string, listener: (event: AgentEvent) => void): () => void {
299
+ if (!this.subscribers.has(bufferId)) {
300
+ this.subscribers.set(bufferId, new Set())
301
+ }
302
+ const subs = this.subscribers.get(bufferId)!
303
+ subs.add(listener)
304
+ return () => {
305
+ subs.delete(listener)
306
+ }
307
+ }
308
+ }
309
+
310
+ // Singleton instance — in moav2 we don't rely on window.__agentService for HMR survival.
311
+ // The entry point manages lifecycle.
312
+ export const agentService = new AgentService()
moav2/src/core/services/db.ts ADDED
@@ -0,0 +1,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Provider {
2
+ id: string
3
+ name: string
4
+ baseUrl: string
5
+ apiKey: string
6
+ createdAt: number
7
+ }
8
+
9
+ export interface StoredOAuthCredentials {
10
+ provider: string // keyPath — e.g. 'anthropic'
11
+ refresh: string
12
+ access: string
13
+ expires: number
14
+ accountId?: string
15
+ }
16
+
17
+ export interface Session {
18
+ id: string
19
+ title: string
20
+ model: string
21
+ systemPrompt?: string
22
+ createdAt: number
23
+ updatedAt: number
24
+ pinned: boolean
25
+ sortOrder: number
26
+ }
27
+
28
+ export interface TerminalTab {
29
+ id: string
30
+ title: string
31
+ createdAt: number
32
+ pinned: boolean
33
+ sortOrder: number
34
+ }
35
+
36
+ export interface BrowserTab {
37
+ id: string
38
+ title: string
39
+ url: string
40
+ createdAt: number
41
+ pinned: boolean
42
+ sortOrder: number
43
+ }
44
+
45
+ export interface MessageBlock {
46
+ id: string
47
+ type: 'text' | 'tool'
48
+ content?: string
49
+ toolCallId?: string
50
+ toolName?: string
51
+ args?: Record<string, any>
52
+ status?: 'running' | 'completed' | 'error'
53
+ result?: string
54
+ }
55
+
56
+ export interface Message {
57
+ id: string
58
+ sessionId: string
59
+ role: 'user' | 'assistant' | 'system'
60
+ content: string
61
+ blocks?: MessageBlock[]
62
+ partial?: boolean
63
+ createdAt: number
64
+ }
65
+
66
+ /** Sort tabs: pinned first (by sortOrder), then unpinned (by sortOrder) */
67
+ function sortTabs<T extends { pinned: boolean; sortOrder: number }>(tabs: T[]): T[] {
68
+ return tabs.sort((a, b) => {
69
+ if (a.pinned && !b.pinned) return -1
70
+ if (!a.pinned && b.pinned) return 1
71
+ return a.sortOrder - b.sortOrder
72
+ })
73
+ }
74
+
75
+ function openDatabase(): Promise<IDBDatabase> {
76
+ return new Promise((resolve, reject) => {
77
+ const request = indexedDB.open('moa', 3)
78
+ request.onupgradeneeded = (event) => {
79
+ const db = (event.target as IDBOpenDBRequest).result
80
+ const oldVersion = event.oldVersion
81
+
82
+ if (oldVersion < 1) {
83
+ const providers = db.createObjectStore('providers', { keyPath: 'id' })
84
+ providers.createIndex('name', 'name', { unique: false })
85
+
86
+ const sessions = db.createObjectStore('sessions', { keyPath: 'id' })
87
+ sessions.createIndex('updatedAt', 'updatedAt', { unique: false })
88
+
89
+ const messages = db.createObjectStore('messages', { keyPath: 'id' })
90
+ messages.createIndex('sessionId', 'sessionId', { unique: false })
91
+ messages.createIndex('createdAt', 'createdAt', { unique: false })
92
+
93
+ const events = db.createObjectStore('events', { keyPath: 'id' })
94
+ events.createIndex('type', 'type', { unique: false })
95
+ }
96
+
97
+ if (oldVersion < 2) {
98
+ db.createObjectStore('oauth-credentials', { keyPath: 'provider' })
99
+ }
100
+
101
+ if (oldVersion < 3) {
102
+ // Add terminal-tabs and browser-tabs stores
103
+ db.createObjectStore('terminal-tabs', { keyPath: 'id' })
104
+ db.createObjectStore('browser-tabs', { keyPath: 'id' })
105
+ // Existing sessions will get pinned/sortOrder defaults on read
106
+ }
107
+ }
108
+ request.onsuccess = () => resolve(request.result)
109
+ request.onerror = () => reject(request.error)
110
+ })
111
+ }
112
+
113
+ function req<T>(request: IDBRequest<T>): Promise<T> {
114
+ return new Promise((resolve, reject) => {
115
+ request.onsuccess = () => resolve(request.result)
116
+ request.onerror = () => reject(request.error)
117
+ })
118
+ }
119
+
120
+ /** Backfill pinned/sortOrder for sessions migrated from v2 */
121
+ function backfillSession(s: Session): Session {
122
+ if (s.pinned === undefined) s.pinned = false
123
+ if (s.sortOrder === undefined) s.sortOrder = s.createdAt
124
+ return s
125
+ }
126
+
127
+ export class DatabaseManager {
128
+ private db: IDBDatabase | null = null
129
+
130
+ async init(): Promise<void> {
131
+ this.db = await openDatabase()
132
+ }
133
+
134
+ private getStore(name: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
135
+ if (!this.db) throw new Error('Database not initialized')
136
+ return this.db.transaction(name, mode).objectStore(name)
137
+ }
138
+
139
+ // === Provider operations ===
140
+
141
+ async addProvider(name: string, baseUrl: string, apiKey: string): Promise<Provider> {
142
+ const provider: Provider = {
143
+ id: crypto.randomUUID(),
144
+ name,
145
+ baseUrl: baseUrl.replace(/\/+$/, ''),
146
+ apiKey,
147
+ createdAt: Date.now(),
148
+ }
149
+ await req(this.getStore('providers', 'readwrite').put(provider))
150
+ return provider
151
+ }
152
+
153
+ async getProvider(id: string): Promise<Provider | null> {
154
+ const result = await req(this.getStore('providers').get(id))
155
+ return result || null
156
+ }
157
+
158
+ async listProviders(): Promise<Provider[]> {
159
+ const all = await req(this.getStore('providers').getAll())
160
+ return all.sort((a: Provider, b: Provider) => a.createdAt - b.createdAt)
161
+ }
162
+
163
+ async updateProvider(id: string, updates: Partial<Pick<Provider, 'name' | 'baseUrl' | 'apiKey'>>): Promise<Provider | null> {
164
+ const store = this.getStore('providers', 'readwrite')
165
+ const provider = await req(store.get(id)) as Provider | undefined
166
+ if (!provider) return null
167
+ if (updates.name !== undefined) provider.name = updates.name
168
+ if (updates.baseUrl !== undefined) provider.baseUrl = updates.baseUrl.replace(/\/+$/, '')
169
+ if (updates.apiKey !== undefined) provider.apiKey = updates.apiKey
170
+ await req(store.put(provider))
171
+ return provider
172
+ }
173
+
174
+ async removeProvider(id: string): Promise<boolean> {
175
+ try {
176
+ await req(this.getStore('providers', 'readwrite').delete(id))
177
+ return true
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ // === OAuth credential operations ===
184
+
185
+ async getOAuthCredentials(provider: string): Promise<StoredOAuthCredentials | null> {
186
+ const result = await req(this.getStore('oauth-credentials').get(provider))
187
+ return result || null
188
+ }
189
+
190
+ async setOAuthCredentials(provider: string, creds: { refresh: string; access: string; expires: number; accountId?: string }): Promise<void> {
191
+ const record: StoredOAuthCredentials = { provider, ...creds }
192
+ await req(this.getStore('oauth-credentials', 'readwrite').put(record))
193
+ }
194
+
195
+ async removeOAuthCredentials(provider: string): Promise<void> {
196
+ await req(this.getStore('oauth-credentials', 'readwrite').delete(provider))
197
+ }
198
+
199
+ // === Session operations ===
200
+
201
+ async createSession(model: string): Promise<Session> {
202
+ const now = Date.now()
203
+ const session: Session = {
204
+ id: crypto.randomUUID(),
205
+ title: 'New Chat',
206
+ model,
207
+ createdAt: now,
208
+ updatedAt: now,
209
+ pinned: false,
210
+ sortOrder: now,
211
+ }
212
+ await req(this.getStore('sessions', 'readwrite').put(session))
213
+ return session
214
+ }
215
+
216
+ async getSession(id: string): Promise<Session | null> {
217
+ const result = await req(this.getStore('sessions').get(id))
218
+ return result ? backfillSession(result) : null
219
+ }
220
+
221
+ async listSessions(): Promise<Session[]> {
222
+ const all = await req(this.getStore('sessions').getAll())
223
+ return sortTabs(all.map(backfillSession))
224
+ }
225
+
226
+ async updateSession(id: string, updates: Partial<Pick<Session, 'title' | 'model' | 'systemPrompt' | 'pinned' | 'sortOrder'>>): Promise<Session | null> {
227
+ const store = this.getStore('sessions', 'readwrite')
228
+ const session = await req(store.get(id)) as Session | undefined
229
+ if (!session) return null
230
+ backfillSession(session)
231
+ if (updates.title !== undefined) session.title = updates.title
232
+ if (updates.model !== undefined) session.model = updates.model
233
+ if (updates.systemPrompt !== undefined) session.systemPrompt = updates.systemPrompt
234
+ if (updates.pinned !== undefined) session.pinned = updates.pinned
235
+ if (updates.sortOrder !== undefined) session.sortOrder = updates.sortOrder
236
+ session.updatedAt = Date.now()
237
+ await req(store.put(session))
238
+ return session
239
+ }
240
+
241
+ async removeSession(id: string): Promise<void> {
242
+ const messages = await this.getMessages(id)
243
+ const msgStore = this.getStore('messages', 'readwrite')
244
+ for (const m of messages) {
245
+ await req(msgStore.delete(m.id))
246
+ }
247
+ await req(this.getStore('sessions', 'readwrite').delete(id))
248
+ }
249
+
250
+ // === Terminal Tab operations ===
251
+
252
+ async createTerminalTab(title?: string): Promise<TerminalTab> {
253
+ const now = Date.now()
254
+ const tab: TerminalTab = {
255
+ id: crypto.randomUUID(),
256
+ title: title || 'New Thread',
257
+ createdAt: now,
258
+ pinned: false,
259
+ sortOrder: now,
260
+ }
261
+ await req(this.getStore('terminal-tabs', 'readwrite').put(tab))
262
+ return tab
263
+ }
264
+
265
+ async listTerminalTabs(): Promise<TerminalTab[]> {
266
+ const all = await req(this.getStore('terminal-tabs').getAll())
267
+ return sortTabs(all)
268
+ }
269
+
270
+ async updateTerminalTab(id: string, updates: Partial<Pick<TerminalTab, 'title' | 'pinned' | 'sortOrder'>>): Promise<TerminalTab | null> {
271
+ const store = this.getStore('terminal-tabs', 'readwrite')
272
+ const tab = await req(store.get(id)) as TerminalTab | undefined
273
+ if (!tab) return null
274
+ if (updates.title !== undefined) tab.title = updates.title
275
+ if (updates.pinned !== undefined) tab.pinned = updates.pinned
276
+ if (updates.sortOrder !== undefined) tab.sortOrder = updates.sortOrder
277
+ await req(store.put(tab))
278
+ return tab
279
+ }
280
+
281
+ async removeTerminalTab(id: string): Promise<void> {
282
+ await req(this.getStore('terminal-tabs', 'readwrite').delete(id))
283
+ }
284
+
285
+ // === Browser Tab operations ===
286
+
287
+ async createBrowserTab(title?: string, url?: string): Promise<BrowserTab> {
288
+ const now = Date.now()
289
+ const tab: BrowserTab = {
290
+ id: crypto.randomUUID(),
291
+ title: title || 'Browser',
292
+ url: url || '',
293
+ createdAt: now,
294
+ pinned: false,
295
+ sortOrder: now,
296
+ }
297
+ await req(this.getStore('browser-tabs', 'readwrite').put(tab))
298
+ return tab
299
+ }
300
+
301
+ async listBrowserTabs(): Promise<BrowserTab[]> {
302
+ const all = await req(this.getStore('browser-tabs').getAll())
303
+ return sortTabs(all)
304
+ }
305
+
306
+ async updateBrowserTab(id: string, updates: Partial<Pick<BrowserTab, 'title' | 'url' | 'pinned' | 'sortOrder'>>): Promise<BrowserTab | null> {
307
+ const store = this.getStore('browser-tabs', 'readwrite')
308
+ const tab = await req(store.get(id)) as BrowserTab | undefined
309
+ if (!tab) return null
310
+ if (updates.title !== undefined) tab.title = updates.title
311
+ if (updates.url !== undefined) tab.url = updates.url
312
+ if (updates.pinned !== undefined) tab.pinned = updates.pinned
313
+ if (updates.sortOrder !== undefined) tab.sortOrder = updates.sortOrder
314
+ await req(store.put(tab))
315
+ return tab
316
+ }
317
+
318
+ async removeBrowserTab(id: string): Promise<void> {
319
+ await req(this.getStore('browser-tabs', 'readwrite').delete(id))
320
+ }
321
+
322
+ // === Message operations ===
323
+
324
+ async addMessage(
325
+ sessionId: string,
326
+ role: 'user' | 'assistant' | 'system',
327
+ content: string,
328
+ opts?: { blocks?: MessageBlock[]; partial?: boolean; id?: string }
329
+ ): Promise<Message> {
330
+ const message: Message = {
331
+ id: opts?.id || crypto.randomUUID(),
332
+ sessionId,
333
+ role,
334
+ content,
335
+ createdAt: Date.now(),
336
+ }
337
+ if (opts?.blocks) message.blocks = opts.blocks
338
+ if (opts?.partial) message.partial = true
339
+ await req(this.getStore('messages', 'readwrite').put(message))
340
+
341
+ const sessionStore = this.getStore('sessions', 'readwrite')
342
+ const session = await req(sessionStore.get(sessionId)) as Session | undefined
343
+ if (session) {
344
+ session.updatedAt = Date.now()
345
+ await req(sessionStore.put(session))
346
+ }
347
+
348
+ return message
349
+ }
350
+
351
+ async updateMessage(id: string, updates: Partial<Pick<Message, 'content' | 'blocks' | 'partial'>>): Promise<void> {
352
+ const store = this.getStore('messages', 'readwrite')
353
+ const msg = await req(store.get(id)) as Message | undefined
354
+ if (!msg) return
355
+ if (updates.content !== undefined) msg.content = updates.content
356
+ if (updates.blocks !== undefined) msg.blocks = updates.blocks
357
+ if (updates.partial !== undefined) msg.partial = updates.partial
358
+ else if (updates.partial === undefined && 'partial' in updates) delete msg.partial
359
+ await req(store.put(msg))
360
+ }
361
+
362
+ async removeMessage(id: string): Promise<void> {
363
+ await req(this.getStore('messages', 'readwrite').delete(id))
364
+ }
365
+
366
+ async getMessages(sessionId: string): Promise<Message[]> {
367
+ const index = this.getStore('messages').index('sessionId')
368
+ const all = await req(index.getAll(sessionId))
369
+ return all.sort((a: Message, b: Message) => a.createdAt - b.createdAt)
370
+ }
371
+ }
372
+
373
+ // Shared singleton instance
374
+ export const db = new DatabaseManager()
375
+
376
+ // Ready promise — consumers can await this before using db
377
+ export const dbReady: Promise<void> = db.init()
moav2/src/core/services/event-store.ts ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Event Store — foundational history primitive for MOA v2
2
+ // Uses PlatformDatabase (async) accessed via getPlatform().sqlite
3
+ // Architecture: append-only event log with FTS5 search and left-fold projections
4
+
5
+ import { getPlatform } from '../platform'
6
+ import type { PlatformDatabase } from '../platform/types'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Types (unchanged from moa v1)
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface MoaEvent {
13
+ id: string // Sortable timestamp-based ID (base36 ts + random)
14
+ type: string // e.g. 'message.sent', 'file.read', 'session.created'
15
+ payload: Record<string, any> // Event-specific data (stored as JSON)
16
+ actor: string // 'user' | 'agent' | 'system'
17
+ sessionId?: string // Optional session context
18
+ causationId?: string // What event/command caused this
19
+ correlationId?: string // Root transaction/session ID
20
+ timestamp: number // Date.now() at append time
21
+ }
22
+
23
+ export type NewEvent = Omit<MoaEvent, 'id' | 'timestamp'>
24
+
25
+ export interface QueryOpts {
26
+ type?: string // Filter by event type. Supports glob: 'message.*'
27
+ sessionId?: string // Filter by session
28
+ actor?: string // Filter by actor
29
+ since?: number // Timestamp lower bound (inclusive)
30
+ until?: number // Timestamp upper bound (inclusive)
31
+ limit?: number // Max results, default 100
32
+ offset?: number // Pagination offset, default 0
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Helpers
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /** Deserialize a row from the events table into a MoaEvent */
40
+ function rowToEvent(row: any): MoaEvent {
41
+ return {
42
+ id: row.id,
43
+ type: row.type,
44
+ payload: JSON.parse(row.payload),
45
+ actor: row.actor,
46
+ sessionId: row.session_id ?? undefined,
47
+ causationId: row.causation_id ?? undefined,
48
+ correlationId: row.correlation_id ?? undefined,
49
+ timestamp: row.timestamp,
50
+ }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // EventStore
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export class EventStore {
58
+ private db: PlatformDatabase
59
+
60
+ private constructor(db: PlatformDatabase) {
61
+ this.db = db
62
+ }
63
+
64
+ /** Async factory — creates and initializes the event store */
65
+ static async create(): Promise<EventStore> {
66
+ const { sqlite } = getPlatform()
67
+ const db = await sqlite.open('moa-events.db')
68
+ const store = new EventStore(db)
69
+ await store.init()
70
+ return store
71
+ }
72
+
73
+ // -------------------------------------------------------------------------
74
+ // Schema initialisation (async — all db ops return Promises)
75
+ // -------------------------------------------------------------------------
76
+
77
+ private async init(): Promise<void> {
78
+ // Core events table — append-only, no UPDATE / DELETE in normal operation
79
+ await this.db.exec(`
80
+ CREATE TABLE IF NOT EXISTS events (
81
+ id TEXT PRIMARY KEY,
82
+ type TEXT NOT NULL,
83
+ payload TEXT NOT NULL,
84
+ actor TEXT NOT NULL,
85
+ session_id TEXT,
86
+ causation_id TEXT,
87
+ correlation_id TEXT,
88
+ timestamp INTEGER NOT NULL
89
+ );
90
+ CREATE INDEX IF NOT EXISTS idx_events_type ON events(type);
91
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
92
+ CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
93
+ CREATE INDEX IF NOT EXISTS idx_events_actor ON events(actor);
94
+ CREATE INDEX IF NOT EXISTS idx_events_corr ON events(correlation_id);
95
+ `)
96
+
97
+ // FTS5 virtual table for full-text search over type + payload text
98
+ // content= keeps FTS in sync via triggers (external content table pattern)
99
+ await this.db.exec(`
100
+ CREATE VIRTUAL TABLE IF NOT EXISTS events_fts USING fts5(
101
+ type,
102
+ payload,
103
+ content=events,
104
+ content_rowid=rowid
105
+ );
106
+ `)
107
+
108
+ // Triggers to keep the FTS index in sync with the events table.
109
+ // We wrap each in a try because CREATE TRIGGER IF NOT EXISTS is not
110
+ // universally supported — the exec will throw if the trigger already exists.
111
+ const triggers = [
112
+ `CREATE TRIGGER events_ai AFTER INSERT ON events BEGIN
113
+ INSERT INTO events_fts(rowid, type, payload)
114
+ VALUES (new.rowid, new.type, new.payload);
115
+ END;`,
116
+ `CREATE TRIGGER events_ad AFTER DELETE ON events BEGIN
117
+ INSERT INTO events_fts(events_fts, rowid, type, payload)
118
+ VALUES ('delete', old.rowid, old.type, old.payload);
119
+ END;`,
120
+ `CREATE TRIGGER events_au AFTER UPDATE ON events BEGIN
121
+ INSERT INTO events_fts(events_fts, rowid, type, payload)
122
+ VALUES ('delete', old.rowid, old.type, old.payload);
123
+ INSERT INTO events_fts(rowid, type, payload)
124
+ VALUES (new.rowid, new.type, new.payload);
125
+ END;`,
126
+ ]
127
+
128
+ for (const sql of triggers) {
129
+ try {
130
+ await this.db.exec(sql)
131
+ } catch (_) {
132
+ // Trigger already exists — safe to ignore
133
+ }
134
+ }
135
+ }
136
+
137
+ // -------------------------------------------------------------------------
138
+ // ID generation — lexicographically sortable by time
139
+ // -------------------------------------------------------------------------
140
+
141
+ private generateId(): string {
142
+ // 9-char base36 timestamp gives us sortability through ~2060
143
+ const ts = Date.now().toString(36).padStart(9, '0')
144
+ // 8-char random suffix for uniqueness within the same millisecond
145
+ const rand = Math.random().toString(36).substring(2, 10)
146
+ return `${ts}-${rand}`
147
+ }
148
+
149
+ // -------------------------------------------------------------------------
150
+ // Write side — append only (async)
151
+ // -------------------------------------------------------------------------
152
+
153
+ /** Append a new event to the log. Returns the complete event with id and timestamp. */
154
+ async append(event: NewEvent): Promise<MoaEvent> {
155
+ const id = this.generateId()
156
+ const timestamp = Date.now()
157
+ const payloadJson = JSON.stringify(event.payload)
158
+
159
+ const stmt = this.db.prepare(`
160
+ INSERT INTO events (id, type, payload, actor, session_id, causation_id, correlation_id, timestamp)
161
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
162
+ `)
163
+
164
+ await stmt.run(
165
+ id,
166
+ event.type,
167
+ payloadJson,
168
+ event.actor,
169
+ event.sessionId ?? null,
170
+ event.causationId ?? null,
171
+ event.correlationId ?? null,
172
+ timestamp,
173
+ )
174
+
175
+ return {
176
+ id,
177
+ type: event.type,
178
+ payload: event.payload,
179
+ actor: event.actor,
180
+ sessionId: event.sessionId,
181
+ causationId: event.causationId,
182
+ correlationId: event.correlationId,
183
+ timestamp,
184
+ }
185
+ }
186
+
187
+ /** Append multiple events sequentially. Browser SQLite may not support transactions the same way. */
188
+ async appendBatch(events: NewEvent[]): Promise<MoaEvent[]> {
189
+ const results: MoaEvent[] = []
190
+ for (const evt of events) {
191
+ results.push(await this.append(evt))
192
+ }
193
+ return results
194
+ }
195
+
196
+ // -------------------------------------------------------------------------
197
+ // Read side — queries (async)
198
+ // -------------------------------------------------------------------------
199
+
200
+ /** Query events with flexible filters. Returns events sorted by timestamp ASC. */
201
+ async query(opts: QueryOpts = {}): Promise<MoaEvent[]> {
202
+ const clauses: string[] = []
203
+ const params: any[] = []
204
+
205
+ if (opts.type) {
206
+ if (opts.type.includes('*')) {
207
+ // Glob pattern: 'message.*' -> LIKE 'message.%'
208
+ clauses.push('type LIKE ?')
209
+ params.push(opts.type.replace(/\*/g, '%'))
210
+ } else {
211
+ clauses.push('type = ?')
212
+ params.push(opts.type)
213
+ }
214
+ }
215
+
216
+ if (opts.sessionId) {
217
+ clauses.push('session_id = ?')
218
+ params.push(opts.sessionId)
219
+ }
220
+
221
+ if (opts.actor) {
222
+ clauses.push('actor = ?')
223
+ params.push(opts.actor)
224
+ }
225
+
226
+ if (opts.since != null) {
227
+ clauses.push('timestamp >= ?')
228
+ params.push(opts.since)
229
+ }
230
+
231
+ if (opts.until != null) {
232
+ clauses.push('timestamp <= ?')
233
+ params.push(opts.until)
234
+ }
235
+
236
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
237
+ const limit = opts.limit ?? 100
238
+ const offset = opts.offset ?? 0
239
+
240
+ const sql = `
241
+ SELECT * FROM events
242
+ ${where}
243
+ ORDER BY timestamp ASC, id ASC
244
+ LIMIT ? OFFSET ?
245
+ `
246
+ params.push(limit, offset)
247
+
248
+ const rows = await this.db.prepare(sql).all(...params)
249
+ return rows.map(rowToEvent)
250
+ }
251
+
252
+ /** Full-text search over event type and payload JSON text. */
253
+ async search(query: string, opts?: { limit?: number; sessionId?: string }): Promise<MoaEvent[]> {
254
+ const limit = opts?.limit ?? 50
255
+ const sessionFilter = opts?.sessionId
256
+
257
+ // FTS5 MATCH requires the query to be non-empty
258
+ if (!query.trim()) return []
259
+
260
+ let sql: string
261
+ const params: any[] = []
262
+
263
+ if (sessionFilter) {
264
+ sql = `
265
+ SELECT e.* FROM events e
266
+ INNER JOIN events_fts f ON e.rowid = f.rowid
267
+ WHERE events_fts MATCH ? AND e.session_id = ?
268
+ ORDER BY rank
269
+ LIMIT ?
270
+ `
271
+ params.push(query, sessionFilter, limit)
272
+ } else {
273
+ sql = `
274
+ SELECT e.* FROM events e
275
+ INNER JOIN events_fts f ON e.rowid = f.rowid
276
+ WHERE events_fts MATCH ?
277
+ ORDER BY rank
278
+ LIMIT ?
279
+ `
280
+ params.push(query, limit)
281
+ }
282
+
283
+ try {
284
+ const rows = await this.db.prepare(sql).all(...params)
285
+ return rows.map(rowToEvent)
286
+ } catch (_) {
287
+ // FTS query syntax errors should not crash the app
288
+ console.warn('[EventStore] FTS search failed for query:', query)
289
+ return []
290
+ }
291
+ }
292
+
293
+ // -------------------------------------------------------------------------
294
+ // Projections (Read Models) — async
295
+ // -------------------------------------------------------------------------
296
+
297
+ /**
298
+ * Left-fold all events for a session into a materialized read model.
299
+ * Returns a categorized snapshot of the session's current state.
300
+ */
301
+ async materialize(sessionId: string): Promise<{
302
+ messages: MoaEvent[]
303
+ intents: MoaEvent[]
304
+ fileOps: MoaEvent[]
305
+ commands: MoaEvent[]
306
+ sessions: MoaEvent[]
307
+ other: MoaEvent[]
308
+ }> {
309
+ const events = await this.query({ sessionId, limit: 10_000 })
310
+
311
+ const result = {
312
+ messages: [] as MoaEvent[],
313
+ intents: [] as MoaEvent[],
314
+ fileOps: [] as MoaEvent[],
315
+ commands: [] as MoaEvent[],
316
+ sessions: [] as MoaEvent[],
317
+ other: [] as MoaEvent[],
318
+ }
319
+
320
+ for (const evt of events) {
321
+ if (evt.type.startsWith('message.')) {
322
+ result.messages.push(evt)
323
+ } else if (evt.type.startsWith('intent.')) {
324
+ result.intents.push(evt)
325
+ } else if (evt.type.startsWith('file.')) {
326
+ result.fileOps.push(evt)
327
+ } else if (evt.type.startsWith('command.')) {
328
+ result.commands.push(evt)
329
+ } else if (evt.type.startsWith('session.')) {
330
+ result.sessions.push(evt)
331
+ } else {
332
+ result.other.push(evt)
333
+ }
334
+ }
335
+
336
+ return result
337
+ }
338
+
339
+ /** Get the most recent event of a given type, optionally within a session. */
340
+ async getLatest(type: string, sessionId?: string): Promise<MoaEvent | null> {
341
+ const clauses = ['type = ?']
342
+ const params: any[] = [type]
343
+
344
+ if (sessionId) {
345
+ clauses.push('session_id = ?')
346
+ params.push(sessionId)
347
+ }
348
+
349
+ const sql = `
350
+ SELECT * FROM events
351
+ WHERE ${clauses.join(' AND ')}
352
+ ORDER BY timestamp DESC, id DESC
353
+ LIMIT 1
354
+ `
355
+
356
+ const row = await this.db.prepare(sql).get(...params)
357
+ return row ? rowToEvent(row) : null
358
+ }
359
+
360
+ /** Get a single event by its ID. */
361
+ async getById(id: string): Promise<MoaEvent | null> {
362
+ const row = await this.db.prepare('SELECT * FROM events WHERE id = ?').get(id)
363
+ return row ? rowToEvent(row) : null
364
+ }
365
+
366
+ /** Count events, optionally filtered by type and/or session. */
367
+ async count(type?: string, sessionId?: string): Promise<number> {
368
+ const clauses: string[] = []
369
+ const params: any[] = []
370
+
371
+ if (type) {
372
+ if (type.includes('*')) {
373
+ clauses.push('type LIKE ?')
374
+ params.push(type.replace(/\*/g, '%'))
375
+ } else {
376
+ clauses.push('type = ?')
377
+ params.push(type)
378
+ }
379
+ }
380
+
381
+ if (sessionId) {
382
+ clauses.push('session_id = ?')
383
+ params.push(sessionId)
384
+ }
385
+
386
+ const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
387
+ const sql = `SELECT COUNT(*) as cnt FROM events ${where}`
388
+
389
+ const row = await this.db.prepare(sql).get(...params) as { cnt: number }
390
+ return row.cnt
391
+ }
392
+
393
+ // -------------------------------------------------------------------------
394
+ // Utility (async)
395
+ // -------------------------------------------------------------------------
396
+
397
+ /** Get all distinct event types in the store. */
398
+ async types(): Promise<string[]> {
399
+ const rows = await this.db.prepare('SELECT DISTINCT type FROM events ORDER BY type').all()
400
+ return rows.map((r: any) => r.type)
401
+ }
402
+
403
+ /** Get all distinct session IDs. */
404
+ async sessions(): Promise<string[]> {
405
+ const rows = await this.db
406
+ .prepare('SELECT DISTINCT session_id FROM events WHERE session_id IS NOT NULL ORDER BY session_id')
407
+ .all()
408
+ return rows.map((r: any) => r.session_id)
409
+ }
410
+
411
+ /**
412
+ * Replay events through a reducer function.
413
+ * This is the generic left-fold — callers supply the fold logic.
414
+ */
415
+ async replay<T>(
416
+ reducer: (state: T, event: MoaEvent) => T,
417
+ initialState: T,
418
+ opts?: QueryOpts,
419
+ ): Promise<T> {
420
+ const events = await this.query({ ...opts, limit: opts?.limit ?? 100_000 })
421
+ let state = initialState
422
+ for (const evt of events) {
423
+ state = reducer(state, evt)
424
+ }
425
+ return state
426
+ }
427
+
428
+ /** Close the database connection. Call on app shutdown. */
429
+ async close(): Promise<void> {
430
+ try {
431
+ await this.db.close()
432
+ } catch (_) {
433
+ // Already closed or never opened
434
+ }
435
+ }
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Singleton — async initialization
440
+ // ---------------------------------------------------------------------------
441
+
442
+ let _eventStore: EventStore | null = null
443
+
444
+ export async function getEventStore(): Promise<EventStore> {
445
+ if (!_eventStore) {
446
+ _eventStore = await EventStore.create()
447
+ }
448
+ return _eventStore
449
+ }
moav2/src/core/services/google-auth.ts ADDED
@@ -0,0 +1,226 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Google OAuth 2.0 for Vertex AI (Gemini models).
3
+ *
4
+ * Flow (same pattern as Anthropic OAuth):
5
+ * 1. Build auth URL → open in browser
6
+ * 2. User authorizes → gets code
7
+ * 3. User pastes code → exchange for tokens
8
+ * 4. Use access_token for Vertex AI API calls
9
+ * 5. Refresh automatically when expired
10
+ *
11
+ * Requires a Google OAuth Client ID (Desktop type) configured in
12
+ * Google Cloud Console. The client_id and client_secret are stored
13
+ * in env/config — for desktop OAuth clients, the secret is not
14
+ * truly secret (shipped in binaries, same as gcloud CLI).
15
+ */
16
+
17
+ // Google OAuth endpoints
18
+ const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'
19
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token'
20
+ const PROJECTS_URL = 'https://cloudresourcemanager.googleapis.com/v1/projects'
21
+
22
+ // Scopes needed for Vertex AI + project listing
23
+ const SCOPES = [
24
+ 'https://www.googleapis.com/auth/cloud-platform',
25
+ ].join(' ')
26
+
27
+ // PKCE: generate code verifier + challenge
28
+ function generateCodeVerifier(): string {
29
+ const array = new Uint8Array(32)
30
+ crypto.getRandomValues(array)
31
+ return base64UrlEncode(array)
32
+ }
33
+
34
+ async function generateCodeChallenge(verifier: string): Promise<string> {
35
+ const encoder = new TextEncoder()
36
+ const data = encoder.encode(verifier)
37
+ const hash = await crypto.subtle.digest('SHA-256', data)
38
+ return base64UrlEncode(new Uint8Array(hash))
39
+ }
40
+
41
+ function base64UrlEncode(bytes: Uint8Array): string {
42
+ let binary = ''
43
+ for (const b of bytes) binary += String.fromCharCode(b)
44
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
45
+ }
46
+
47
+ export interface GoogleOAuthConfig {
48
+ clientId: string
49
+ clientSecret: string
50
+ }
51
+
52
+ export interface GoogleOAuthTokens {
53
+ access_token: string
54
+ refresh_token: string
55
+ expires_at: number // Unix ms
56
+ token_type: string
57
+ }
58
+
59
+ export interface GcpProject {
60
+ projectId: string
61
+ name: string
62
+ projectNumber: string
63
+ }
64
+
65
+ // In-memory state for the current OAuth flow
66
+ let _codeVerifier: string | null = null
67
+
68
+ /**
69
+ * Get the configured OAuth client credentials.
70
+ * Reads from environment or hardcoded config.
71
+ */
72
+ export function getOAuthConfig(): GoogleOAuthConfig | null {
73
+ // Check environment first (for development)
74
+ const clientId = (import.meta as any).env?.VITE_GOOGLE_OAUTH_CLIENT_ID
75
+ const clientSecret = (import.meta as any).env?.VITE_GOOGLE_OAUTH_CLIENT_SECRET
76
+
77
+ if (clientId && clientSecret) {
78
+ return { clientId, clientSecret }
79
+ }
80
+
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * Build the Google OAuth authorization URL.
86
+ * Opens this in the browser for the user to authorize.
87
+ */
88
+ export async function buildAuthUrl(config: GoogleOAuthConfig): Promise<{ url: string }> {
89
+ _codeVerifier = generateCodeVerifier()
90
+ const codeChallenge = await generateCodeChallenge(_codeVerifier)
91
+
92
+ const params = new URLSearchParams({
93
+ client_id: config.clientId,
94
+ redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
95
+ response_type: 'code',
96
+ scope: SCOPES,
97
+ code_challenge: codeChallenge,
98
+ code_challenge_method: 'S256',
99
+ access_type: 'offline',
100
+ prompt: 'consent',
101
+ })
102
+
103
+ return { url: `${AUTH_URL}?${params.toString()}` }
104
+ }
105
+
106
+ /**
107
+ * Exchange the authorization code for tokens.
108
+ */
109
+ export async function exchangeCode(
110
+ config: GoogleOAuthConfig,
111
+ code: string
112
+ ): Promise<GoogleOAuthTokens> {
113
+ if (!_codeVerifier) {
114
+ throw new Error('No code verifier found — did you call buildAuthUrl first?')
115
+ }
116
+
117
+ const body = new URLSearchParams({
118
+ client_id: config.clientId,
119
+ client_secret: config.clientSecret,
120
+ code,
121
+ code_verifier: _codeVerifier,
122
+ grant_type: 'authorization_code',
123
+ redirect_uri: 'urn:ietf:wg:oauth:2.0:oob',
124
+ })
125
+
126
+ const res = await fetch(TOKEN_URL, {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
129
+ body: body.toString(),
130
+ })
131
+
132
+ if (!res.ok) {
133
+ const err = await res.text()
134
+ throw new Error(`Token exchange failed (${res.status}): ${err}`)
135
+ }
136
+
137
+ const data = await res.json()
138
+ _codeVerifier = null
139
+
140
+ return {
141
+ access_token: data.access_token,
142
+ refresh_token: data.refresh_token,
143
+ expires_at: Date.now() + (data.expires_in * 1000),
144
+ token_type: data.token_type || 'Bearer',
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Refresh an expired access token using the refresh token.
150
+ */
151
+ export async function refreshAccessToken(
152
+ config: GoogleOAuthConfig,
153
+ refreshToken: string
154
+ ): Promise<{ access_token: string; expires_at: number }> {
155
+ const body = new URLSearchParams({
156
+ client_id: config.clientId,
157
+ client_secret: config.clientSecret,
158
+ refresh_token: refreshToken,
159
+ grant_type: 'refresh_token',
160
+ })
161
+
162
+ const res = await fetch(TOKEN_URL, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
165
+ body: body.toString(),
166
+ })
167
+
168
+ if (!res.ok) {
169
+ const err = await res.text()
170
+ throw new Error(`Token refresh failed (${res.status}): ${err}`)
171
+ }
172
+
173
+ const data = await res.json()
174
+ return {
175
+ access_token: data.access_token,
176
+ expires_at: Date.now() + (data.expires_in * 1000),
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Get a valid access token, refreshing if needed.
182
+ * Returns the token string ready for Authorization header.
183
+ */
184
+ export async function getValidAccessToken(
185
+ config: GoogleOAuthConfig,
186
+ stored: { refresh: string; access: string; expires: number }
187
+ ): Promise<{ accessToken: string; newCreds: { refresh: string; access: string; expires: number } }> {
188
+ // Token still valid (with 60s buffer)
189
+ if (stored.expires > Date.now() + 60_000) {
190
+ return {
191
+ accessToken: stored.access,
192
+ newCreds: stored,
193
+ }
194
+ }
195
+
196
+ // Need to refresh
197
+ const refreshed = await refreshAccessToken(config, stored.refresh)
198
+ const newCreds = {
199
+ refresh: stored.refresh,
200
+ access: refreshed.access_token,
201
+ expires: refreshed.expires_at,
202
+ }
203
+
204
+ return { accessToken: refreshed.access_token, newCreds }
205
+ }
206
+
207
+ /**
208
+ * List GCP projects the authenticated user has access to.
209
+ */
210
+ export async function listProjects(accessToken: string): Promise<GcpProject[]> {
211
+ const res = await fetch(`${PROJECTS_URL}?filter=lifecycleState:ACTIVE`, {
212
+ headers: { 'Authorization': `Bearer ${accessToken}` },
213
+ })
214
+
215
+ if (!res.ok) {
216
+ const err = await res.text()
217
+ throw new Error(`Failed to list projects (${res.status}): ${err}`)
218
+ }
219
+
220
+ const data = await res.json()
221
+ return (data.projects || []).map((p: any) => ({
222
+ projectId: p.projectId,
223
+ name: p.name,
224
+ projectNumber: p.projectNumber,
225
+ }))
226
+ }
moav2/src/core/services/google-vertex-express.ts ADDED
@@ -0,0 +1,372 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Vertex AI Express mode streaming provider.
3
+ *
4
+ * Uses a simple API key with `vertexai: true` in the @google/genai SDK,
5
+ * which triggers the Express endpoint (aiplatform.googleapis.com) without
6
+ * requiring a GCP project or location.
7
+ */
8
+
9
+ import { GoogleGenAI, ThinkingLevel } from '@google/genai'
10
+ import { calculateCost } from '../../../node_modules/@mariozechner/pi-ai/dist/models.js'
11
+ import { AssistantMessageEventStream } from '../../../node_modules/@mariozechner/pi-ai/dist/utils/event-stream.js'
12
+ import { sanitizeSurrogates } from '../../../node_modules/@mariozechner/pi-ai/dist/utils/sanitize-unicode.js'
13
+ import {
14
+ convertMessages,
15
+ convertTools,
16
+ isThinkingPart,
17
+ mapStopReason,
18
+ mapToolChoice,
19
+ retainThoughtSignature,
20
+ } from '../../../node_modules/@mariozechner/pi-ai/dist/providers/google-shared.js'
21
+ import { buildBaseOptions, clampReasoning } from '../../../node_modules/@mariozechner/pi-ai/dist/providers/simple-options.js'
22
+ import { withRetry } from './retry'
23
+
24
+ const API_VERSION = 'v1'
25
+
26
+ const THINKING_LEVEL_MAP: Record<string, any> = {
27
+ THINKING_LEVEL_UNSPECIFIED: ThinkingLevel.THINKING_LEVEL_UNSPECIFIED,
28
+ MINIMAL: ThinkingLevel.MINIMAL,
29
+ LOW: ThinkingLevel.LOW,
30
+ MEDIUM: ThinkingLevel.MEDIUM,
31
+ HIGH: ThinkingLevel.HIGH,
32
+ }
33
+
34
+ let toolCallCounter = 0
35
+
36
+ function createClient(apiKey: string, optionsHeaders?: Record<string, string>) {
37
+ const httpOptions: any = {}
38
+ if (optionsHeaders) {
39
+ httpOptions.headers = { ...optionsHeaders }
40
+ }
41
+ const hasHttpOptions = Object.values(httpOptions).some(Boolean)
42
+ return new GoogleGenAI({
43
+ apiKey,
44
+ vertexai: true,
45
+ // No project or location — SDK enters Express mode automatically
46
+ apiVersion: API_VERSION,
47
+ httpOptions: hasHttpOptions ? httpOptions : undefined,
48
+ })
49
+ }
50
+
51
+ function buildParams(model: any, context: any, options: any = {}) {
52
+ const contents = convertMessages(model, context)
53
+ const generationConfig: any = {}
54
+ if (options.temperature !== undefined) {
55
+ generationConfig.temperature = options.temperature
56
+ }
57
+ if (options.maxTokens !== undefined) {
58
+ generationConfig.maxOutputTokens = options.maxTokens
59
+ }
60
+ const config: any = {
61
+ ...(Object.keys(generationConfig).length > 0 && generationConfig),
62
+ ...(context.systemPrompt && { systemInstruction: sanitizeSurrogates(context.systemPrompt) }),
63
+ ...(context.tools && context.tools.length > 0 && { tools: convertTools(context.tools) }),
64
+ }
65
+ if (context.tools && context.tools.length > 0 && options.toolChoice) {
66
+ config.toolConfig = {
67
+ functionCallingConfig: {
68
+ mode: mapToolChoice(options.toolChoice),
69
+ },
70
+ }
71
+ } else {
72
+ config.toolConfig = undefined
73
+ }
74
+ if (options.thinking?.enabled && model.reasoning) {
75
+ const thinkingConfig: any = { includeThoughts: true }
76
+ if (options.thinking.level !== undefined) {
77
+ thinkingConfig.thinkingLevel = THINKING_LEVEL_MAP[options.thinking.level]
78
+ } else if (options.thinking.budgetTokens !== undefined) {
79
+ thinkingConfig.thinkingBudget = options.thinking.budgetTokens
80
+ }
81
+ config.thinkingConfig = thinkingConfig
82
+ }
83
+ if (options.signal) {
84
+ if (options.signal.aborted) {
85
+ throw new Error('Request aborted')
86
+ }
87
+ config.abortSignal = options.signal
88
+ }
89
+ return { model: model.id, contents, config }
90
+ }
91
+
92
+ export const streamGoogleVertexExpress = (model: any, context: any, options: any) => {
93
+ const stream = new AssistantMessageEventStream()
94
+ ;(async () => {
95
+ const output: any = {
96
+ role: 'assistant',
97
+ content: [],
98
+ api: 'google-vertex-express',
99
+ provider: model.provider,
100
+ model: model.id,
101
+ usage: {
102
+ input: 0,
103
+ output: 0,
104
+ cacheRead: 0,
105
+ cacheWrite: 0,
106
+ totalTokens: 0,
107
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
108
+ },
109
+ stopReason: 'stop',
110
+ timestamp: Date.now(),
111
+ }
112
+ try {
113
+ const apiKey = options?.apiKey
114
+ if (!apiKey) {
115
+ throw new Error('Vertex AI Express requires an API key. Go to Settings > Vertex AI and add your Express API key.')
116
+ }
117
+ const client = createClient(apiKey, options?.headers)
118
+ const params = buildParams(model, context, options)
119
+ options?.onPayload?.(params)
120
+ const googleStream = await withRetry(
121
+ () => client.models.generateContentStream(params),
122
+ {
123
+ signal: options?.signal,
124
+ onRetry: ({ attempt, maxRetries, delayMs, error }) => {
125
+ const message = error instanceof Error ? error.message : String(error)
126
+ console.warn(`[vertex-express] Retry ${attempt}/${maxRetries} after ${delayMs}ms:`, message)
127
+ },
128
+ }
129
+ )
130
+ stream.push({ type: 'start', partial: output })
131
+ let currentBlock: any = null
132
+ const blocks = output.content
133
+ const blockIndex = () => blocks.length - 1
134
+ for await (const chunk of googleStream) {
135
+ const candidate = (chunk as any).candidates?.[0]
136
+ if (candidate?.content?.parts) {
137
+ for (const part of candidate.content.parts) {
138
+ if (part.text !== undefined) {
139
+ const isThinking = isThinkingPart(part)
140
+ if (
141
+ !currentBlock ||
142
+ (isThinking && currentBlock.type !== 'thinking') ||
143
+ (!isThinking && currentBlock.type !== 'text')
144
+ ) {
145
+ if (currentBlock) {
146
+ if (currentBlock.type === 'text') {
147
+ stream.push({
148
+ type: 'text_end',
149
+ contentIndex: blocks.length - 1,
150
+ content: currentBlock.text,
151
+ partial: output,
152
+ })
153
+ } else {
154
+ stream.push({
155
+ type: 'thinking_end',
156
+ contentIndex: blockIndex(),
157
+ content: currentBlock.thinking,
158
+ partial: output,
159
+ })
160
+ }
161
+ }
162
+ if (isThinking) {
163
+ currentBlock = { type: 'thinking', thinking: '', thinkingSignature: undefined }
164
+ output.content.push(currentBlock)
165
+ stream.push({ type: 'thinking_start', contentIndex: blockIndex(), partial: output })
166
+ } else {
167
+ currentBlock = { type: 'text', text: '' }
168
+ output.content.push(currentBlock)
169
+ stream.push({ type: 'text_start', contentIndex: blockIndex(), partial: output })
170
+ }
171
+ }
172
+ if (currentBlock.type === 'thinking') {
173
+ currentBlock.thinking += part.text
174
+ currentBlock.thinkingSignature = retainThoughtSignature(
175
+ currentBlock.thinkingSignature,
176
+ (part as any).thoughtSignature
177
+ )
178
+ stream.push({
179
+ type: 'thinking_delta',
180
+ contentIndex: blockIndex(),
181
+ delta: part.text,
182
+ partial: output,
183
+ })
184
+ } else {
185
+ currentBlock.text += part.text
186
+ currentBlock.textSignature = retainThoughtSignature(
187
+ currentBlock.textSignature,
188
+ (part as any).thoughtSignature
189
+ )
190
+ stream.push({
191
+ type: 'text_delta',
192
+ contentIndex: blockIndex(),
193
+ delta: part.text,
194
+ partial: output,
195
+ })
196
+ }
197
+ }
198
+ if ((part as any).functionCall) {
199
+ if (currentBlock) {
200
+ if (currentBlock.type === 'text') {
201
+ stream.push({
202
+ type: 'text_end',
203
+ contentIndex: blockIndex(),
204
+ content: currentBlock.text,
205
+ partial: output,
206
+ })
207
+ } else {
208
+ stream.push({
209
+ type: 'thinking_end',
210
+ contentIndex: blockIndex(),
211
+ content: currentBlock.thinking,
212
+ partial: output,
213
+ })
214
+ }
215
+ currentBlock = null
216
+ }
217
+ const fc = (part as any).functionCall
218
+ const providedId = fc.id
219
+ const needsNewId =
220
+ !providedId || output.content.some((b: any) => b.type === 'toolCall' && b.id === providedId)
221
+ const toolCallId = needsNewId ? `${fc.name}_${Date.now()}_${++toolCallCounter}` : providedId
222
+ const toolCall = {
223
+ type: 'toolCall' as const,
224
+ id: toolCallId,
225
+ name: fc.name || '',
226
+ arguments: fc.args ?? {},
227
+ ...((part as any).thoughtSignature && { thoughtSignature: (part as any).thoughtSignature }),
228
+ }
229
+ output.content.push(toolCall)
230
+ stream.push({ type: 'toolcall_start', contentIndex: blockIndex(), partial: output })
231
+ stream.push({
232
+ type: 'toolcall_delta',
233
+ contentIndex: blockIndex(),
234
+ delta: JSON.stringify(toolCall.arguments),
235
+ partial: output,
236
+ })
237
+ stream.push({ type: 'toolcall_end', contentIndex: blockIndex(), toolCall, partial: output })
238
+ }
239
+ }
240
+ }
241
+ if (candidate?.finishReason) {
242
+ output.stopReason = mapStopReason(candidate.finishReason)
243
+ if (output.content.some((b: any) => b.type === 'toolCall')) {
244
+ output.stopReason = 'toolUse'
245
+ }
246
+ }
247
+ if ((chunk as any).usageMetadata) {
248
+ const um = (chunk as any).usageMetadata
249
+ output.usage = {
250
+ input: um.promptTokenCount || 0,
251
+ output: (um.candidatesTokenCount || 0) + (um.thoughtsTokenCount || 0),
252
+ cacheRead: um.cachedContentTokenCount || 0,
253
+ cacheWrite: 0,
254
+ totalTokens: um.totalTokenCount || 0,
255
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
256
+ }
257
+ calculateCost(model, output.usage)
258
+ }
259
+ }
260
+ if (currentBlock) {
261
+ if (currentBlock.type === 'text') {
262
+ stream.push({
263
+ type: 'text_end',
264
+ contentIndex: blockIndex(),
265
+ content: currentBlock.text,
266
+ partial: output,
267
+ })
268
+ } else {
269
+ stream.push({
270
+ type: 'thinking_end',
271
+ contentIndex: blockIndex(),
272
+ content: currentBlock.thinking,
273
+ partial: output,
274
+ })
275
+ }
276
+ }
277
+ if (options?.signal?.aborted) {
278
+ throw new Error('Request was aborted')
279
+ }
280
+ if (output.stopReason === 'aborted' || output.stopReason === 'error') {
281
+ throw new Error('An unknown error occurred')
282
+ }
283
+ stream.push({ type: 'done', reason: output.stopReason, message: output })
284
+ stream.end()
285
+ } catch (error: any) {
286
+ for (const block of output.content) {
287
+ if ('index' in block) {
288
+ delete block.index
289
+ }
290
+ }
291
+ output.stopReason = options?.signal?.aborted ? 'aborted' : 'error'
292
+ output.errorMessage = error instanceof Error ? error.message : JSON.stringify(error)
293
+ stream.push({ type: 'error', reason: output.stopReason, error: output })
294
+ stream.end()
295
+ }
296
+ })()
297
+ return stream
298
+ }
299
+
300
+ function isGemini3ProModel(model: any) {
301
+ return model.id.includes('3-pro')
302
+ }
303
+
304
+ function isGemini3FlashModel(model: any) {
305
+ return model.id.includes('3-flash')
306
+ }
307
+
308
+ function getGemini3ThinkingLevel(effort: string, model: any): string {
309
+ if (isGemini3ProModel(model)) {
310
+ switch (effort) {
311
+ case 'minimal':
312
+ case 'low':
313
+ return 'LOW'
314
+ case 'medium':
315
+ case 'high':
316
+ return 'HIGH'
317
+ }
318
+ }
319
+ switch (effort) {
320
+ case 'minimal':
321
+ return 'MINIMAL'
322
+ case 'low':
323
+ return 'LOW'
324
+ case 'medium':
325
+ return 'MEDIUM'
326
+ case 'high':
327
+ return 'HIGH'
328
+ }
329
+ return 'MEDIUM'
330
+ }
331
+
332
+ function getGoogleBudget(model: any, effort: string, customBudgets: any): number {
333
+ if (customBudgets?.[effort] !== undefined) {
334
+ return customBudgets[effort]
335
+ }
336
+ if (model.id.includes('2.5-pro')) {
337
+ const budgets: Record<string, number> = { minimal: 128, low: 2048, medium: 8192, high: 32768 }
338
+ return budgets[effort] ?? -1
339
+ }
340
+ if (model.id.includes('2.5-flash')) {
341
+ const budgets: Record<string, number> = { minimal: 128, low: 2048, medium: 8192, high: 24576 }
342
+ return budgets[effort] ?? -1
343
+ }
344
+ return -1
345
+ }
346
+
347
+ export const streamSimpleGoogleVertexExpress = (model: any, context: any, options: any) => {
348
+ const base = buildBaseOptions(model, options, undefined)
349
+ if (!options?.reasoning) {
350
+ return streamGoogleVertexExpress(model, context, {
351
+ ...base,
352
+ thinking: { enabled: false },
353
+ })
354
+ }
355
+ const effort = clampReasoning(options.reasoning) as string
356
+ if (isGemini3ProModel(model) || isGemini3FlashModel(model)) {
357
+ return streamGoogleVertexExpress(model, context, {
358
+ ...base,
359
+ thinking: {
360
+ enabled: true,
361
+ level: getGemini3ThinkingLevel(effort, model),
362
+ },
363
+ })
364
+ }
365
+ return streamGoogleVertexExpress(model, context, {
366
+ ...base,
367
+ thinking: {
368
+ enabled: true,
369
+ budgetTokens: getGoogleBudget(model, effort, options.thinkingBudgets),
370
+ },
371
+ })
372
+ }
moav2/src/core/services/model-resolver.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Model } from '@mariozechner/pi-ai'
2
+
3
+ export type AuthMethod = 'anthropic-key' | 'anthropic-oauth' | 'openai-oauth' | 'vertex' | 'vertex-express' | string
4
+
5
+ interface ResolveModelParams {
6
+ modelId: string
7
+ authMethod: AuthMethod
8
+ providerBaseUrl?: string
9
+ }
10
+
11
+ export async function resolveModel(params: ResolveModelParams): Promise<Model<any>> {
12
+ const piAi = await import('@mariozechner/pi-ai')
13
+ const { modelId, authMethod } = params
14
+
15
+ // Anthropic (both API key and OAuth use the same model objects)
16
+ if (authMethod === 'anthropic-key' || authMethod === 'anthropic-oauth') {
17
+ try {
18
+ const found = piAi.getModel('anthropic', modelId as any)
19
+ if (found) return found
20
+ } catch {
21
+ // Not in registry, fall through
22
+ }
23
+ }
24
+
25
+ // Vertex AI
26
+ if (authMethod === 'vertex') {
27
+ try {
28
+ const found = piAi.getModel('google-vertex', modelId as any)
29
+ if (found) return found
30
+ } catch {
31
+ // Not in registry, fall through
32
+ }
33
+ }
34
+
35
+ // Vertex AI Express (API key auth, global endpoint)
36
+ if (authMethod === 'vertex-express') {
37
+ try {
38
+ const found = piAi.getModel('google-vertex', modelId as any)
39
+ if (found) return found
40
+ } catch {
41
+ // Not in registry, fall through
42
+ }
43
+ }
44
+
45
+ // OpenAI OAuth (Codex)
46
+ if (authMethod === 'openai-oauth') {
47
+ try {
48
+ const found = piAi.getModel('openai-codex', modelId as any)
49
+ if (found) return found
50
+ } catch {
51
+ // Not in registry, fall through
52
+ }
53
+ }
54
+
55
+ // Custom provider — scan all known registries then fall back
56
+ const knownProviders = piAi.getProviders()
57
+ for (const p of knownProviders) {
58
+ try {
59
+ const models = piAi.getModels(p)
60
+ const found = models.find((m: any) => m.id === modelId)
61
+ if (found) return found
62
+ } catch {
63
+ // Skip
64
+ }
65
+ }
66
+
67
+ // Fall back to a custom model config
68
+ const providerBaseUrl = params.providerBaseUrl || ''
69
+ return {
70
+ id: modelId,
71
+ name: modelId,
72
+ api: 'openai-completions',
73
+ provider: authMethod || 'custom',
74
+ baseUrl: providerBaseUrl,
75
+ reasoning: false,
76
+ input: ['text', 'image'] as ('text' | 'image')[],
77
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
78
+ contextWindow: 200000,
79
+ maxTokens: 16384,
80
+ } as Model<any>
81
+ }
82
+
83
+ /** Get all models for a known provider from pi-ai registry */
84
+ export async function getProviderModels(provider: string): Promise<Model<any>[]> {
85
+ const piAi = await import('@mariozechner/pi-ai')
86
+ try {
87
+ return piAi.getModels(provider as any)
88
+ } catch {
89
+ return []
90
+ }
91
+ }
moav2/src/core/services/oauth-code-bridge.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ interface OAuthCodeResolver {
2
+ resolve: (code: string) => void
3
+ reject: (error: Error) => void
4
+ }
5
+
6
+ export class OAuthCodeBridge {
7
+ private resolver: OAuthCodeResolver | null = null
8
+ private pendingCode: string | null = null
9
+
10
+ cancel(reason: string) {
11
+ const error = new Error(reason)
12
+ this.resolver?.reject(error)
13
+ this.resolver = null
14
+ this.pendingCode = null
15
+ }
16
+
17
+ attachResolver(resolver: OAuthCodeResolver) {
18
+ this.resolver = resolver
19
+ if (this.pendingCode) {
20
+ const code = this.pendingCode
21
+ this.pendingCode = null
22
+ this.resolver.resolve(code)
23
+ this.resolver = null
24
+ return true
25
+ }
26
+ return false
27
+ }
28
+
29
+ submitCode(code: string) {
30
+ const trimmed = code.trim()
31
+ if (!trimmed) return { accepted: false, queued: false }
32
+
33
+ if (this.resolver) {
34
+ this.resolver.resolve(trimmed)
35
+ this.resolver = null
36
+ return { accepted: true, queued: false }
37
+ }
38
+
39
+ this.pendingCode = trimmed
40
+ return { accepted: false, queued: true }
41
+ }
42
+ }
moav2/src/core/services/provider-guards.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AuthMethod } from './model-resolver'
2
+
3
+ export interface ResolvedProviderModel {
4
+ authMethod: AuthMethod
5
+ modelId: string
6
+ }
7
+
8
+ export function parseProviderModel(model: string): ResolvedProviderModel {
9
+ const sepIdx = model.indexOf(':')
10
+ if (sepIdx === -1) {
11
+ return { authMethod: 'anthropic-key', modelId: model }
12
+ }
13
+ return {
14
+ authMethod: model.substring(0, sepIdx),
15
+ modelId: model.substring(sepIdx + 1),
16
+ }
17
+ }
18
+
19
+ export function resolveVertexFallback(
20
+ model: string,
21
+ vertexProject: string | null,
22
+ vertexLocation: string | null,
23
+ vertexExpressApiKey: string | null,
24
+ ): ResolvedProviderModel {
25
+ const parsed = parseProviderModel(model)
26
+ if (parsed.authMethod !== 'vertex') return parsed
27
+
28
+ const hasFullVertexConfig = Boolean(vertexProject && vertexLocation)
29
+ if (hasFullVertexConfig) return parsed
30
+
31
+ if (vertexExpressApiKey) {
32
+ return { authMethod: 'vertex-express', modelId: parsed.modelId }
33
+ }
34
+
35
+ throw new Error('Vertex AI is not configured. Add Vertex project/location or set a Vertex Express API key in Settings > Vertex AI.')
36
+ }
37
+
38
+ export function getAnthropicBrowserAuthError(authMethod: AuthMethod, platformType: 'browser' | 'electron' | 'capacitor'): string | null {
39
+ if (platformType === 'browser' && authMethod === 'anthropic-key') {
40
+ return 'Anthropic API keys are blocked by browser CORS policy. In Settings > Anthropic, switch to OAuth and sign in with Claude.'
41
+ }
42
+ return null
43
+ }
moav2/src/core/services/retry.ts ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const DEFAULT_MAX_RETRIES = 4
2
+ export const DEFAULT_INITIAL_DELAY_MS = 500
3
+ export const DEFAULT_MAX_DELAY_MS = 30_000
4
+ export const DEFAULT_JITTER_RATIO = 0.25
5
+
6
+ export interface RetryAttemptInfo {
7
+ attempt: number
8
+ maxRetries: number
9
+ delayMs: number
10
+ error: unknown
11
+ }
12
+
13
+ export interface WithRetryOptions {
14
+ maxRetries?: number
15
+ initialDelayMs?: number
16
+ maxDelayMs?: number
17
+ jitterRatio?: number
18
+ signal?: AbortSignal
19
+ onRetry?: (info: RetryAttemptInfo) => void
20
+ }
21
+
22
+ function toNumber(value: unknown): number | null {
23
+ if (typeof value === 'number' && Number.isFinite(value)) return value
24
+ if (typeof value === 'string') {
25
+ const parsed = Number(value)
26
+ if (Number.isFinite(parsed)) return parsed
27
+ }
28
+ return null
29
+ }
30
+
31
+ function createAbortError() {
32
+ return new Error('Request was aborted')
33
+ }
34
+
35
+ export function isRetryableError(error: unknown): boolean {
36
+ const err = error as any
37
+ const statusCandidates = [err?.status, err?.statusCode, err?.response?.status, err?.code]
38
+ for (const candidate of statusCandidates) {
39
+ const status = toNumber(candidate)
40
+ if (status === 429 || (status !== null && status >= 500 && status < 600)) {
41
+ return true
42
+ }
43
+ }
44
+
45
+ const message = [
46
+ err?.message,
47
+ err?.name,
48
+ err?.code,
49
+ err?.statusText,
50
+ err?.cause?.message,
51
+ typeof error === 'string' ? error : undefined,
52
+ ]
53
+ .filter((part): part is string => typeof part === 'string' && part.length > 0)
54
+ .join(' ')
55
+ .toLowerCase()
56
+
57
+ if (/\b429\b/.test(message) || /\b5\d\d\b/.test(message)) {
58
+ return true
59
+ }
60
+
61
+ const retryableKeywords = [
62
+ 'resource_exhausted',
63
+ 'internal',
64
+ 'unavailable',
65
+ 'deadline_exceeded',
66
+ 'econnreset',
67
+ 'econnrefused',
68
+ 'fetch failed',
69
+ 'timeout',
70
+ 'timed out',
71
+ 'rate limit',
72
+ 'overloaded',
73
+ 'capacity',
74
+ ]
75
+
76
+ return retryableKeywords.some(keyword => message.includes(keyword))
77
+ }
78
+
79
+ export async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
80
+ if (signal?.aborted) {
81
+ throw createAbortError()
82
+ }
83
+
84
+ await new Promise<void>((resolve, reject) => {
85
+ const timeout = setTimeout(() => {
86
+ signal?.removeEventListener('abort', onAbort)
87
+ resolve()
88
+ }, Math.max(0, ms))
89
+
90
+ const onAbort = () => {
91
+ clearTimeout(timeout)
92
+ signal?.removeEventListener('abort', onAbort)
93
+ reject(createAbortError())
94
+ }
95
+
96
+ signal?.addEventListener('abort', onAbort, { once: true })
97
+ })
98
+ }
99
+
100
+ export async function withRetry<T>(fn: () => Promise<T>, options: WithRetryOptions = {}): Promise<T> {
101
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
102
+ const initialDelayMs = options.initialDelayMs ?? DEFAULT_INITIAL_DELAY_MS
103
+ const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
104
+ const jitterRatio = options.jitterRatio ?? DEFAULT_JITTER_RATIO
105
+
106
+ for (let attempt = 0; ; attempt++) {
107
+ if (options.signal?.aborted) {
108
+ throw createAbortError()
109
+ }
110
+
111
+ try {
112
+ return await fn()
113
+ } catch (error) {
114
+ if (options.signal?.aborted) {
115
+ throw createAbortError()
116
+ }
117
+ if (attempt >= maxRetries || !isRetryableError(error)) {
118
+ throw error
119
+ }
120
+
121
+ const baseDelay = Math.min(initialDelayMs * 2 ** attempt, maxDelayMs)
122
+ const jitteredDelay = Math.min(maxDelayMs, Math.round(baseDelay * (1 + Math.random() * jitterRatio)))
123
+
124
+ options.onRetry?.({
125
+ attempt: attempt + 1,
126
+ maxRetries,
127
+ delayMs: jitteredDelay,
128
+ error,
129
+ })
130
+
131
+ await sleep(jitteredDelay, options.signal)
132
+ }
133
+ }
134
+ }
moav2/src/core/services/runtime-pack.ts ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getPlatform } from '../platform'
2
+
3
+ export interface RuntimeAgentConfig {
4
+ systemPromptAppendix?: string
5
+ toolAllowlist?: string[]
6
+ scripts?: Record<string, RuntimeScript>
7
+ }
8
+
9
+ export interface RuntimeScript {
10
+ type: 'bash'
11
+ command: string
12
+ description?: string
13
+ }
14
+
15
+ interface RuntimePackManifest {
16
+ id: string
17
+ version: string
18
+ createdAt: number
19
+ configChecksum: string
20
+ }
21
+
22
+ interface RuntimePackState {
23
+ activePackId: string
24
+ previousPackId?: string
25
+ }
26
+
27
+ export interface RuntimePackInfo {
28
+ enabled: boolean
29
+ runtimeRoot: string
30
+ activePackId: string | null
31
+ previousPackId: string | null
32
+ availablePacks: string[]
33
+ }
34
+
35
+ const DEFAULT_PACK_ID = 'builtin-v1'
36
+
37
+ function runtimePackEnabled(): boolean {
38
+ const type = getPlatform().type
39
+ return type === 'electron' || type === 'capacitor'
40
+ }
41
+
42
+ function runtimeRoot(): string {
43
+ const platform = getPlatform()
44
+ if (platform.type === 'electron') {
45
+ return platform.path.join(platform.process.homedir(), '.moa-runtime')
46
+ }
47
+ return '/moa-runtime'
48
+ }
49
+
50
+ function packsDir(): string {
51
+ return `${runtimeRoot()}/packs`
52
+ }
53
+
54
+ function stateFile(): string {
55
+ return `${runtimeRoot()}/state.json`
56
+ }
57
+
58
+ function normalizeAllowlist(config: RuntimeAgentConfig): string[] {
59
+ const list = config.toolAllowlist && config.toolAllowlist.length > 0
60
+ ? config.toolAllowlist
61
+ : ['bash']
62
+ return list.filter(Boolean)
63
+ }
64
+
65
+ function checksumFnv1a(input: string): string {
66
+ let hash = 0x811c9dc5
67
+ for (let i = 0; i < input.length; i += 1) {
68
+ hash ^= input.charCodeAt(i)
69
+ hash +=
70
+ (hash << 1) +
71
+ (hash << 4) +
72
+ (hash << 7) +
73
+ (hash << 8) +
74
+ (hash << 24)
75
+ }
76
+ return (hash >>> 0).toString(16).padStart(8, '0')
77
+ }
78
+
79
+ function packDir(packId: string): string {
80
+ return `${packsDir()}/${packId}`
81
+ }
82
+
83
+ function manifestPath(packId: string): string {
84
+ return `${packDir(packId)}/manifest.json`
85
+ }
86
+
87
+ function configPath(packId: string): string {
88
+ return `${packDir(packId)}/agent-config.json`
89
+ }
90
+
91
+ function ensureDir(path: string): void {
92
+ const { fs } = getPlatform()
93
+ if (!fs.existsSync(path)) {
94
+ fs.mkdirSync(path, { recursive: true })
95
+ }
96
+ }
97
+
98
+ function readJsonSync<T>(path: string): T | null {
99
+ const { fs } = getPlatform()
100
+ if (!fs.existsSync(path)) return null
101
+ try {
102
+ const raw = fs.readFileSync(path, 'utf-8')
103
+ return JSON.parse(raw) as T
104
+ } catch {
105
+ return null
106
+ }
107
+ }
108
+
109
+ function writeJsonSync(path: string, value: unknown): void {
110
+ const { fs, path: pathApi } = getPlatform()
111
+ ensureDir(pathApi.dirname(path))
112
+ fs.writeFileSync(path, JSON.stringify(value, null, 2))
113
+ }
114
+
115
+ function defaultAgentConfig(): RuntimeAgentConfig {
116
+ return {
117
+ systemPromptAppendix: [
118
+ 'Runtime Pack active.',
119
+ 'Prefer editing only approved runtime scripts/config in the runtime packs store before touching host code.',
120
+ 'Treat host app shell as immutable runtime unless explicitly requested.',
121
+ ].join('\n'),
122
+ toolAllowlist: ['bash'],
123
+ scripts: {},
124
+ }
125
+ }
126
+
127
+ function isRuntimeScript(value: unknown): value is RuntimeScript {
128
+ if (!value || typeof value !== 'object') return false
129
+ const record = value as Record<string, unknown>
130
+ if (record.type !== 'bash') return false
131
+ if (typeof record.command !== 'string' || record.command.trim().length === 0) return false
132
+ if (record.description !== undefined && typeof record.description !== 'string') return false
133
+ return true
134
+ }
135
+
136
+ function validateConfig(config: RuntimeAgentConfig): boolean {
137
+ if (config.systemPromptAppendix !== undefined && typeof config.systemPromptAppendix !== 'string') return false
138
+ if (config.toolAllowlist !== undefined) {
139
+ if (!Array.isArray(config.toolAllowlist)) return false
140
+ if (!config.toolAllowlist.every((item) => typeof item === 'string')) return false
141
+ }
142
+ if (config.scripts !== undefined) {
143
+ if (typeof config.scripts !== 'object' || config.scripts === null) return false
144
+ for (const script of Object.values(config.scripts)) {
145
+ if (!isRuntimeScript(script)) return false
146
+ }
147
+ }
148
+ return true
149
+ }
150
+
151
+ class RuntimePackService {
152
+ private initialized = false
153
+
154
+ initializeSync(): void {
155
+ if (this.initialized) return
156
+ if (!runtimePackEnabled()) {
157
+ this.initialized = true
158
+ return
159
+ }
160
+
161
+ ensureDir(runtimeRoot())
162
+ ensureDir(packsDir())
163
+
164
+ const state = readJsonSync<RuntimePackState>(stateFile())
165
+ if (!state) {
166
+ const initialConfig = defaultAgentConfig()
167
+ const serializedConfig = JSON.stringify(initialConfig, null, 2)
168
+ const checksum = checksumFnv1a(serializedConfig)
169
+ writeJsonSync(configPath(DEFAULT_PACK_ID), initialConfig)
170
+ writeJsonSync(manifestPath(DEFAULT_PACK_ID), {
171
+ id: DEFAULT_PACK_ID,
172
+ version: '1.0.0',
173
+ createdAt: Date.now(),
174
+ configChecksum: checksum,
175
+ } satisfies RuntimePackManifest)
176
+ writeJsonSync(stateFile(), { activePackId: DEFAULT_PACK_ID } satisfies RuntimePackState)
177
+ }
178
+
179
+ this.initialized = true
180
+ }
181
+
182
+ async initialize(): Promise<void> {
183
+ this.initializeSync()
184
+ }
185
+
186
+ getActiveConfigSync(): RuntimeAgentConfig | null {
187
+ if (!runtimePackEnabled()) return null
188
+ this.initializeSync()
189
+
190
+ const state = readJsonSync<RuntimePackState>(stateFile())
191
+ if (!state?.activePackId) return null
192
+
193
+ return this.loadPackConfigSync(state.activePackId)
194
+ }
195
+
196
+ getInfoSync(): RuntimePackInfo {
197
+ if (!runtimePackEnabled()) {
198
+ return {
199
+ enabled: false,
200
+ runtimeRoot: runtimeRoot(),
201
+ activePackId: null,
202
+ previousPackId: null,
203
+ availablePacks: [],
204
+ }
205
+ }
206
+
207
+ this.initializeSync()
208
+ const state = readJsonSync<RuntimePackState>(stateFile())
209
+ return {
210
+ enabled: true,
211
+ runtimeRoot: runtimeRoot(),
212
+ activePackId: state?.activePackId ?? null,
213
+ previousPackId: state?.previousPackId ?? null,
214
+ availablePacks: this.listPackIdsSync(),
215
+ }
216
+ }
217
+
218
+ listPackIdsSync(): string[] {
219
+ if (!runtimePackEnabled()) return []
220
+ this.initializeSync()
221
+ const { fs } = getPlatform()
222
+ if (!fs.existsSync(packsDir())) return []
223
+ return fs.readdirSync(packsDir())
224
+ }
225
+
226
+ activatePackSync(packId: string): boolean {
227
+ if (!runtimePackEnabled()) return false
228
+ this.initializeSync()
229
+ const state = readJsonSync<RuntimePackState>(stateFile())
230
+ if (!state) return false
231
+
232
+ const config = this.loadPackConfigSync(packId)
233
+ if (!config) return false
234
+
235
+ const nextState = {
236
+ activePackId: packId,
237
+ previousPackId: state.activePackId,
238
+ } satisfies RuntimePackState
239
+
240
+ writeJsonSync(stateFile(), nextState)
241
+
242
+ const activationCheck = this.getActiveConfigSync()
243
+ if (!activationCheck) {
244
+ writeJsonSync(stateFile(), state)
245
+ return false
246
+ }
247
+
248
+ return true
249
+ }
250
+
251
+ rollbackSync(): boolean {
252
+ if (!runtimePackEnabled()) return false
253
+ this.initializeSync()
254
+ const state = readJsonSync<RuntimePackState>(stateFile())
255
+ if (!state?.previousPackId) return false
256
+ return this.activatePackSync(state.previousPackId)
257
+ }
258
+
259
+ writePackSync(packId: string, version: string, config: RuntimeAgentConfig): void {
260
+ if (!runtimePackEnabled()) return
261
+ this.initializeSync()
262
+ if (!validateConfig(config)) {
263
+ throw new Error(`Invalid runtime pack config for ${packId}`)
264
+ }
265
+
266
+ const serializedConfig = JSON.stringify(config, null, 2)
267
+ const checksum = checksumFnv1a(serializedConfig)
268
+
269
+ writeJsonSync(configPath(packId), config)
270
+ writeJsonSync(manifestPath(packId), {
271
+ id: packId,
272
+ version,
273
+ createdAt: Date.now(),
274
+ configChecksum: checksum,
275
+ } satisfies RuntimePackManifest)
276
+ }
277
+
278
+ private loadPackConfigSync(packId: string): RuntimeAgentConfig | null {
279
+ const manifest = readJsonSync<RuntimePackManifest>(manifestPath(packId))
280
+ const config = readJsonSync<RuntimeAgentConfig>(configPath(packId))
281
+ if (!manifest || !config) return null
282
+
283
+ const serializedConfig = JSON.stringify(config, null, 2)
284
+ const checksum = checksumFnv1a(serializedConfig)
285
+ if (checksum !== manifest.configChecksum) return null
286
+ if (!validateConfig(config)) return null
287
+
288
+ return config
289
+ }
290
+
291
+ async executeScript(scriptName: string): Promise<{ ok: boolean; output: string; packId: string | null }> {
292
+ if (!runtimePackEnabled()) {
293
+ return { ok: false, output: 'Runtime packs are disabled on this platform.', packId: null }
294
+ }
295
+ this.initializeSync()
296
+
297
+ const state = readJsonSync<RuntimePackState>(stateFile())
298
+ if (!state?.activePackId) {
299
+ return { ok: false, output: 'No active runtime pack.', packId: null }
300
+ }
301
+
302
+ const config = this.loadPackConfigSync(state.activePackId)
303
+ if (!config) {
304
+ const rolledBack = this.rollbackSync()
305
+ return {
306
+ ok: false,
307
+ output: rolledBack
308
+ ? `Active runtime pack is invalid. Rolled back to previous pack.`
309
+ : 'Active runtime pack is invalid and no rollback target is available.',
310
+ packId: state.activePackId,
311
+ }
312
+ }
313
+
314
+ const script = config.scripts?.[scriptName]
315
+ if (!script) {
316
+ return { ok: false, output: `Script not found: ${scriptName}`, packId: state.activePackId }
317
+ }
318
+
319
+ const allowlist = normalizeAllowlist(config)
320
+ if (!allowlist.includes(script.type)) {
321
+ return {
322
+ ok: false,
323
+ output: `Script type '${script.type}' is blocked by allowlist. Allowed: ${allowlist.join(', ')}`,
324
+ packId: state.activePackId,
325
+ }
326
+ }
327
+
328
+ const { process } = getPlatform()
329
+ const result = await process.exec(script.command, {
330
+ timeout: 30000,
331
+ maxBuffer: 1024 * 1024,
332
+ cwd: process.cwd(),
333
+ })
334
+
335
+ if (result.exitCode !== 0) {
336
+ const message = `Script '${scriptName}' failed (exit ${result.exitCode}).\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`
337
+ return { ok: false, output: message, packId: state.activePackId }
338
+ }
339
+
340
+ return {
341
+ ok: true,
342
+ output: result.stdout || `Script '${scriptName}' completed successfully.`,
343
+ packId: state.activePackId,
344
+ }
345
+ }
346
+ }
347
+
348
+ export const runtimePackService = new RuntimePackService()
moav2/src/core/services/session-store.ts ADDED
@@ -0,0 +1,911 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getPlatform } from '../platform'
2
+ import { AgentEvent } from '@mariozechner/pi-agent-core'
3
+ import { getOAuthApiKey } from '@mariozechner/pi-ai'
4
+ import { agentService, StreamingBlock } from './agent-service'
5
+ import { db, dbReady } from './db'
6
+ import { resolveModel, type AuthMethod } from './model-resolver'
7
+ import { getOAuthConfig, getValidAccessToken } from './google-auth'
8
+ import { logAction } from './action-logger'
9
+ import { runtimePackService } from './runtime-pack'
10
+ import { isRetryableError, withRetry } from './retry'
11
+ import { hasAssistantResponseStarted } from './session-waiting'
12
+ import { getAnthropicBrowserAuthError, resolveVertexFallback } from './provider-guards'
13
+ import { createAllTools, createSetSystemPromptTool } from '../tools'
14
+
15
+ export type DisplayBlock = StreamingBlock
16
+
17
+ export interface DisplayMessage {
18
+ id: string
19
+ role: 'user' | 'assistant' | 'system'
20
+ blocks: DisplayBlock[]
21
+ createdAt: number
22
+ }
23
+
24
+ export interface SessionState {
25
+ messages: DisplayMessage[]
26
+ streamingBlocks: DisplayBlock[]
27
+ input: string
28
+ isStreaming: boolean
29
+ isWaitingForResponse: boolean
30
+ expandedTools: Set<string>
31
+ agentReady: boolean
32
+ isLoading: boolean
33
+ model: string
34
+ /** Last send error, cleared on next successful send or manual dismiss */
35
+ sendError: string | null
36
+ }
37
+
38
+ const DEFAULT_STATE: SessionState = Object.freeze({
39
+ messages: [],
40
+ streamingBlocks: [],
41
+ input: '',
42
+ isStreaming: false,
43
+ isWaitingForResponse: false,
44
+ expandedTools: new Set<string>(),
45
+ agentReady: false,
46
+ isLoading: true,
47
+ model: '',
48
+ sendError: null,
49
+ })
50
+
51
+ /** Builds a user-friendly error message from a raw error */
52
+ function friendlyErrorMessage(error: any): string {
53
+ const msg = error?.message || String(error)
54
+ const lower = msg.toLowerCase()
55
+
56
+ // API key issues
57
+ if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication'))
58
+ return `Authentication failed. Check your API key or re-authenticate.\n\nDetails: ${msg}`
59
+ if (lower.includes('403') || lower.includes('forbidden') || lower.includes('permission'))
60
+ return `Access denied. Your API key may lack permissions for this model.\n\nDetails: ${msg}`
61
+
62
+ // Rate limits
63
+ if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests'))
64
+ return `Rate limited by the provider. Retrying automatically...`
65
+
66
+ // Server issues
67
+ if (lower.includes('500') || lower.includes('internal server error'))
68
+ return `Provider server error (500). Retrying...`
69
+ if (lower.includes('502') || lower.includes('bad gateway'))
70
+ return `Provider temporarily unavailable (502). Retrying...`
71
+ if (lower.includes('503') || lower.includes('service unavailable') || lower.includes('overloaded'))
72
+ return `Provider is overloaded or unavailable. Retrying...`
73
+
74
+ // Network
75
+ if (lower.includes('network') || lower.includes('econnrefused') || lower.includes('econnreset'))
76
+ return `Network error. Check your internet connection.\n\nDetails: ${msg}`
77
+ if (lower.includes('timeout') || lower.includes('timed out'))
78
+ return `Request timed out. The provider may be slow or unreachable.`
79
+
80
+ // Model issues
81
+ if (lower.includes('model') && (lower.includes('not found') || lower.includes('does not exist')))
82
+ return `Model not found. The selected model may not be available for your account.\n\nDetails: ${msg}`
83
+
84
+ return msg
85
+ }
86
+
87
+ // Notify Vite dev server about streaming state so HMR gate can defer updates
88
+ function notifyHmrStreamingState(streaming: boolean) {
89
+ try {
90
+ const hot = (import.meta as any).hot
91
+ if (hot) {
92
+ hot.send('moa:streaming-state', { streaming })
93
+ }
94
+ } catch {
95
+ // Not critical
96
+ }
97
+ }
98
+
99
+ function isE2EMockVertexResponseEnabled(): boolean {
100
+ return typeof window !== 'undefined' && (window as any).__MOA_E2E_MOCK_VERTEX_RESPONSE__ === true
101
+ }
102
+
103
+ function buildDefaultSystemPrompt(cwd: string): string {
104
+ return `You are MOA (Malleable Operating Agent) — a self-editing AI operating in a Ralph loop.
105
+
106
+ You run inside an Electron app with three integrated surfaces:
107
+ - Agent buffer (this chat — text editing and code generation)
108
+ - Terminal buffer (full PTY shell access via bash tool)
109
+ - Browser buffer (web browsing via web_fetch tool)
110
+
111
+ Working directory: ${cwd}
112
+ MOA source: ${cwd}/src
113
+
114
+ == Core Primitives ==
115
+ - HISTORY: Search and browse past conversations (history tool). Your memory spans sessions.
116
+ - SEARCH: Search files, code, and history (search tool). Find anything in the codebase or past work.
117
+ - INTENT: Track goals and progress (intent tool). Declare what you're working on, update progress, mark complete.
118
+
119
+ == Self-Modification ==
120
+ You can edit your own source code at ${cwd}/src/. When you do:
121
+ 1. Vite HMR detects changes and hot-reloads the UI immediately
122
+ 2. Use self_inspect to map your codebase before making changes
123
+ 3. Use intent to track multi-step self-modification goals
124
+
125
+ == Tools ==
126
+ File I/O: read, write, edit
127
+ Shell: bash (30s timeout, use for git, npm, system commands)
128
+ Web: web_fetch (fetch and read web pages)
129
+ Search: search (rg/grep across files, filename glob, or history)
130
+ Memory: intent (declare/update/recall/complete/abandon goals)
131
+ History: history (list sessions, get messages, search across conversations)
132
+ Meta: self_inspect (map source tree, read own code, list tools, architecture, runtime state)
133
+
134
+ When asked to make changes, always read the relevant files first, then make targeted edits.
135
+ Use intent to track complex multi-step goals. Use history to recall past context.`
136
+ }
137
+
138
+ export class SessionStore {
139
+ private sessions = new Map<string, SessionState>()
140
+ private listeners = new Set<() => void>()
141
+ private activeSubscriptions = new Map<string, () => void>()
142
+ /** RAF handle for throttled notify — null when no frame is pending */
143
+ private notifyRAF: number | null = null
144
+
145
+ constructor() {
146
+ // Bind methods to ensure 'this' context
147
+ this.initSession = this.initSession.bind(this)
148
+ this.getSession = this.getSession.bind(this)
149
+ this.sendMessage = this.sendMessage.bind(this)
150
+ this.setInput = this.setInput.bind(this)
151
+ this.toggleTool = this.toggleTool.bind(this)
152
+ this.subscribe = this.subscribe.bind(this)
153
+ }
154
+
155
+ getSnapshot(): Map<string, SessionState> {
156
+ return this.sessions
157
+ }
158
+
159
+ getSession(sessionId: string): SessionState {
160
+ return this.sessions.get(sessionId) || DEFAULT_STATE
161
+ }
162
+
163
+ updateSession(sessionId: string, update: Partial<SessionState>, sync = false) {
164
+ const current = this.getSession(sessionId)
165
+ const next = { ...current, ...update }
166
+ // Notify Vite HMR gate when streaming state changes
167
+ if ('isStreaming' in update && update.isStreaming !== current.isStreaming) {
168
+ notifyHmrStreamingState(!!update.isStreaming)
169
+ }
170
+ this.sessions.set(sessionId, next)
171
+ // Use synchronous notify for critical user-facing changes (input, send,
172
+ // streaming end, errors). Streaming content updates use throttled RAF.
173
+ if (sync) {
174
+ this.notifySync()
175
+ } else {
176
+ this.notify()
177
+ }
178
+ }
179
+
180
+ subscribe(listener: () => void): () => void {
181
+ this.listeners.add(listener)
182
+ return () => this.listeners.delete(listener)
183
+ }
184
+
185
+ /**
186
+ * Throttled notify — batches React re-renders to at most once per animation
187
+ * frame (~60fps). During streaming, dozens of text_delta events fire per
188
+ * second; without throttling each one triggers a full React reconciliation.
189
+ */
190
+ private notify() {
191
+ if (this.notifyRAF !== null) return // already scheduled
192
+ this.notifyRAF = requestAnimationFrame(() => {
193
+ this.notifyRAF = null
194
+ for (const listener of this.listeners) {
195
+ listener()
196
+ }
197
+ })
198
+ }
199
+
200
+ /** Bypass RAF and notify synchronously — used for critical state changes
201
+ * like session init, send, and streaming end where we want immediate UI. */
202
+ private notifySync() {
203
+ if (this.notifyRAF !== null) {
204
+ cancelAnimationFrame(this.notifyRAF)
205
+ this.notifyRAF = null
206
+ }
207
+ for (const listener of this.listeners) {
208
+ listener()
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Resolve model string to a Model object and build the getApiKey callback.
214
+ * Shared by initSession (first-time creation) and updateModel (hot-swap).
215
+ */
216
+ private async resolveModelAndAuth(model: string) {
217
+ const vertexProject = localStorage.getItem('vertex_project')
218
+ const vertexLocation = localStorage.getItem('vertex_location')
219
+ const vertexExpressApiKey = localStorage.getItem('vertex_express_api_key')
220
+
221
+ const resolvedProvider = resolveVertexFallback(model, vertexProject, vertexLocation, vertexExpressApiKey)
222
+ const authMethod: AuthMethod = resolvedProvider.authMethod
223
+ const modelId = resolvedProvider.modelId
224
+
225
+ // For Vertex AI, set env vars before model resolution
226
+ if (authMethod === 'vertex') {
227
+ const platform = getPlatform()
228
+ if (vertexProject) platform.process.env.GOOGLE_CLOUD_PROJECT = vertexProject
229
+ if (vertexLocation) platform.process.env.GOOGLE_CLOUD_LOCATION = vertexLocation
230
+ }
231
+
232
+ // For custom providers, look up base URL
233
+ let providerBaseUrl = ''
234
+ if (authMethod !== 'anthropic-key' && authMethod !== 'anthropic-oauth' && authMethod !== 'openai-oauth' && authMethod !== 'vertex' && authMethod !== 'vertex-express') {
235
+ const provider = await db.getProvider(authMethod)
236
+ if (provider) providerBaseUrl = provider.baseUrl
237
+ }
238
+
239
+ const resolvedModel = await resolveModel({
240
+ modelId,
241
+ authMethod,
242
+ providerBaseUrl,
243
+ })
244
+
245
+ const getApiKey = async (providerName: string) => {
246
+ // Anthropic API key
247
+ if (authMethod === 'anthropic-key') {
248
+ const browserAuthError = getAnthropicBrowserAuthError(authMethod, getPlatform().type)
249
+ if (browserAuthError) {
250
+ throw new Error(browserAuthError)
251
+ }
252
+ const key = localStorage.getItem('anthropic_key')
253
+ if (!key) {
254
+ throw new Error('No Anthropic API key configured. Go to Settings and add your API key.')
255
+ }
256
+ return key
257
+ }
258
+ // Anthropic OAuth (Plan)
259
+ if (authMethod === 'anthropic-oauth') {
260
+ const creds = await db.getOAuthCredentials('anthropic')
261
+ if (!creds) {
262
+ throw new Error('Anthropic OAuth not configured. Go to Settings and sign in with your Anthropic account.')
263
+ }
264
+ try {
265
+ const result = await getOAuthApiKey('anthropic', { anthropic: creds as any })
266
+ if (result) {
267
+ // Persist refreshed credentials
268
+ await db.setOAuthCredentials('anthropic', result.newCredentials)
269
+ return result.apiKey
270
+ }
271
+ } catch (e: any) {
272
+ throw new Error(`Anthropic OAuth token refresh failed: ${e.message || e}. Try re-authenticating in Settings.`)
273
+ }
274
+ throw new Error('Anthropic OAuth: failed to obtain API key. Try re-authenticating in Settings.')
275
+ }
276
+ // OpenAI OAuth (ChatGPT Plan)
277
+ if (authMethod === 'openai-oauth') {
278
+ const creds = await db.getOAuthCredentials('openai')
279
+ if (!creds) {
280
+ throw new Error('OpenAI OAuth not configured. Go to Settings and sign in with your OpenAI account.')
281
+ }
282
+ try {
283
+ // DB key is "openai" while OAuth provider key is "openai-codex".
284
+ const result = await getOAuthApiKey('openai-codex', {
285
+ 'openai-codex': creds as any,
286
+ })
287
+ if (result) {
288
+ await db.setOAuthCredentials('openai', result.newCredentials as any)
289
+ return result.apiKey
290
+ }
291
+ } catch (e: any) {
292
+ throw new Error(`OpenAI OAuth token refresh failed: ${e.message || e}. Try re-authenticating in Settings.`)
293
+ }
294
+ throw new Error('OpenAI OAuth: failed to obtain API key. Try re-authenticating in Settings.')
295
+ }
296
+ // Vertex AI — either ADC or Google OAuth
297
+ if (authMethod === 'vertex') {
298
+ const vertexAuthMethod = localStorage.getItem('vertex_auth_method')
299
+ if (vertexAuthMethod === 'google-oauth') {
300
+ // Google OAuth: get/refresh access token and set as model header
301
+ const googleCreds = await db.getOAuthCredentials('google')
302
+ const oauthConfig = getOAuthConfig()
303
+ if (!googleCreds) {
304
+ throw new Error('Google OAuth not configured for Vertex AI. Go to Settings and sign in with Google.')
305
+ }
306
+ if (!oauthConfig) {
307
+ throw new Error('Google OAuth client not configured. Set VITE_GOOGLE_OAUTH_CLIENT_ID and VITE_GOOGLE_OAUTH_CLIENT_SECRET.')
308
+ }
309
+ try {
310
+ const { accessToken, newCreds } = await getValidAccessToken(oauthConfig, googleCreds)
311
+ await db.setOAuthCredentials('google', newCreds)
312
+ // Set Authorization header on the resolved model so @google/genai uses it
313
+ ;(resolvedModel as any).headers = {
314
+ ...(resolvedModel as any).headers,
315
+ 'Authorization': `Bearer ${accessToken}`,
316
+ }
317
+ return '<authenticated>'
318
+ } catch (e: any) {
319
+ throw new Error(`Google OAuth token refresh failed: ${e.message || e}. Try re-authenticating in Settings.`)
320
+ }
321
+ }
322
+ // ADC handles auth
323
+ return '<authenticated>'
324
+ }
325
+ // Vertex AI Express — simple API key
326
+ if (authMethod === 'vertex-express') {
327
+ const key = localStorage.getItem('vertex_express_api_key')
328
+ if (!key) {
329
+ throw new Error('No Vertex AI Express API key configured. Go to Settings > Vertex AI and add your API key.')
330
+ }
331
+ return key
332
+ }
333
+ // Custom provider — look up API key from DB
334
+ const storedProvider = await db.getProvider(authMethod)
335
+ if (storedProvider?.apiKey) return storedProvider.apiKey
336
+ // Fallback: search by name
337
+ const storedProviders = await db.listProviders()
338
+ const match = storedProviders.find(p =>
339
+ p.name.toLowerCase() === providerName.toLowerCase() ||
340
+ p.name.toLowerCase().includes(providerName.toLowerCase())
341
+ )
342
+ if (!match?.apiKey) {
343
+ throw new Error(`No API key found for provider "${authMethod}". Go to Settings and add the provider with an API key.`)
344
+ }
345
+ return match.apiKey
346
+ }
347
+
348
+ // Eagerly validate that we can obtain an API key. This catches
349
+ // missing keys before any streaming attempt so the user sees a
350
+ // clear error immediately instead of a cryptic 401 mid-stream.
351
+ try {
352
+ await getApiKey(resolvedModel.provider)
353
+ } catch (e: any) {
354
+ // Re-throw so callers can surface the message. The lazy
355
+ // callback will throw the same error when the agent loop
356
+ // calls it, so this doesn't hide anything.
357
+ throw e
358
+ }
359
+
360
+ return { resolvedModel, getApiKey, authMethod, modelId }
361
+ }
362
+
363
+ /**
364
+ * Hot-swap the model on an existing session without destroying the agent.
365
+ * Preserves conversation history, tools, subscriptions, and streaming state.
366
+ * Also persists the new model to the DB.
367
+ */
368
+ async updateModel(sessionId: string, newModel: string) {
369
+ await dbReady
370
+
371
+ const { resolvedModel, getApiKey } = await this.resolveModelAndAuth(newModel)
372
+
373
+ agentService.updateModel(sessionId, resolvedModel, getApiKey)
374
+
375
+ // Update in-memory state
376
+ this.updateSession(sessionId, { model: newModel })
377
+
378
+ // Persist to DB
379
+ db.updateSession(sessionId, { model: newModel }).catch(console.error)
380
+ }
381
+
382
+ private composeSystemPrompt(basePrompt: string): string {
383
+ const runtimeInfo = runtimePackService.getInfoSync()
384
+ const runtimeConfig = runtimePackService.getActiveConfigSync()
385
+ const runtimeOverlay = runtimeConfig?.systemPromptAppendix
386
+ ? `Runtime root: ${runtimeInfo.runtimeRoot}\nActive pack: ${runtimeInfo.activePackId ?? '(none)'}\n\n${runtimeConfig.systemPromptAppendix}`
387
+ : null
388
+ return runtimeOverlay
389
+ ? `${basePrompt}\n\n== Runtime Pack Overlay ==\n${runtimeOverlay}`
390
+ : basePrompt
391
+ }
392
+
393
+ async setSystemPrompt(sessionId: string, prompt: string): Promise<void> {
394
+ const nextPrompt = prompt.trim()
395
+ if (!nextPrompt) throw new Error('System prompt cannot be empty.')
396
+
397
+ await dbReady
398
+ const updated = await db.updateSession(sessionId, { systemPrompt: nextPrompt })
399
+ if (!updated) throw new Error(`Session ${sessionId} not found.`)
400
+
401
+ const agent = agentService.getAgent(sessionId)
402
+ if (agent) {
403
+ agent.setSystemPrompt(this.composeSystemPrompt(nextPrompt))
404
+ }
405
+
406
+ const now = Date.now()
407
+ const id = `sys-${now}`
408
+ const content = 'System prompt updated for this session.'
409
+ const s = this.getSession(sessionId)
410
+ this.updateSession(sessionId, {
411
+ messages: [...s.messages, {
412
+ id,
413
+ role: 'system',
414
+ blocks: [{ id: `sys-blk-${now}`, type: 'text', content }],
415
+ createdAt: now,
416
+ }],
417
+ }, true)
418
+
419
+ db.addMessage(sessionId, 'system', content, { id }).catch(() => {})
420
+ logAction('session.system_prompt.updated', { sessionId, length: nextPrompt.length }, { actor: 'agent', sessionId })
421
+ }
422
+
423
+ async initSession(sessionId: string, model: string) {
424
+ // If we already have state for this session and the model matches (or isn't set yet), don't reload
425
+ // BUT if the component re-mounted, we might need to re-verify streaming state
426
+ const existing = this.sessions.get(sessionId)
427
+ if (existing && existing.agentReady && existing.model === model) {
428
+ // Just sync streaming state in case it drifted
429
+ this.syncStreamingState(sessionId)
430
+ return
431
+ }
432
+
433
+ // If agent already exists but model changed, hot-swap the model instead of recreating
434
+ if (existing && existing.agentReady && existing.model !== model && agentService.getAgent(sessionId)) {
435
+ try {
436
+ await this.updateModel(sessionId, model)
437
+ this.syncStreamingState(sessionId)
438
+ return
439
+ } catch (e: any) {
440
+ console.error('Failed to hot-swap model, falling back to full reinit:', e)
441
+ // Fall through to full re-initialization below
442
+ agentService.destroyAgent(sessionId)
443
+ }
444
+ }
445
+
446
+ // Initialize with default if new
447
+ if (!existing) {
448
+ this.sessions.set(sessionId, { ...DEFAULT_STATE, model })
449
+ } else {
450
+ // Update model if changed
451
+ this.updateSession(sessionId, { model })
452
+ }
453
+
454
+ try {
455
+ await dbReady
456
+ const sessionRecord = await db.getSession(sessionId)
457
+
458
+ // Model-less session: load history but skip agent initialization.
459
+ if (!model) {
460
+ const msgs = await db.getMessages(sessionId)
461
+ const loaded: DisplayMessage[] = msgs.map((m: any) => ({
462
+ id: m.id,
463
+ role: m.role,
464
+ blocks: m.blocks
465
+ ? (m.blocks as DisplayBlock[])
466
+ : [{ id: `loaded-${m.id}`, type: 'text' as const, content: m.content }],
467
+ createdAt: m.createdAt,
468
+ }))
469
+ this.updateSession(sessionId, {
470
+ messages: loaded,
471
+ isLoading: false,
472
+ agentReady: false,
473
+ model: '',
474
+ }, true)
475
+ return
476
+ }
477
+
478
+ // 1. Create Agent
479
+ const { resolvedModel, getApiKey } = await this.resolveModelAndAuth(model)
480
+
481
+ const cwd = getPlatform().process.cwd()
482
+ const baseSystemPrompt = sessionRecord?.systemPrompt?.trim() || buildDefaultSystemPrompt(cwd)
483
+ const composedSystemPrompt = this.composeSystemPrompt(baseSystemPrompt)
484
+ const tools = createAllTools('/src')
485
+ tools.push(createSetSystemPromptTool({
486
+ applySystemPrompt: async (prompt) => {
487
+ await this.setSystemPrompt(sessionId, prompt)
488
+ },
489
+ }))
490
+
491
+ agentService.createAgent(sessionId, {
492
+ model: resolvedModel,
493
+ tools,
494
+ systemPrompt: composedSystemPrompt,
495
+ getApiKey,
496
+ })
497
+
498
+ this.updateSession(sessionId, { agentReady: true })
499
+
500
+ // 2. Load Messages from DB
501
+ const msgs = await db.getMessages(sessionId)
502
+
503
+ // Check if we are streaming NOW (survived HMR)
504
+ const isStreaming = agentService.isStreaming(sessionId)
505
+
506
+ const loaded: DisplayMessage[] = msgs
507
+ .filter((m: any) => !m.partial || !isStreaming) // Don't show partials if we are about to show live blocks
508
+ .map((m: any) => ({
509
+ id: m.id,
510
+ role: m.role,
511
+ blocks: m.blocks
512
+ ? (m.blocks as DisplayBlock[])
513
+ : [{ id: `loaded-${m.id}`, type: 'text' as const, content: m.content }],
514
+ createdAt: m.createdAt,
515
+ }))
516
+
517
+ this.updateSession(sessionId, {
518
+ messages: loaded,
519
+ isLoading: false
520
+ })
521
+
522
+ // 3. Hydrate Agent History
523
+ const nonPartial = msgs.filter((m: any) => !m.partial)
524
+ agentService.hydrateFromMessages(sessionId, nonPartial)
525
+
526
+ // 4. Sync Streaming State
527
+ this.syncStreamingState(sessionId)
528
+
529
+ // 5. Subscribe to Agent Events
530
+ // Ensure we don't double-subscribe
531
+ if (this.activeSubscriptions.has(sessionId)) {
532
+ this.activeSubscriptions.get(sessionId)!()
533
+ }
534
+
535
+ const unsub = agentService.subscribe(sessionId, (event) => this.handleAgentEvent(sessionId, event))
536
+ this.activeSubscriptions.set(sessionId, unsub)
537
+
538
+ } catch (e: any) {
539
+ console.error('Failed to init session:', e)
540
+ this.updateSession(sessionId, {
541
+ isLoading: false,
542
+ messages: [...(this.getSession(sessionId).messages), {
543
+ id: `err-${Date.now()}`,
544
+ role: 'system',
545
+ blocks: [{ id: `err-blk-${Date.now()}`, type: 'text', content: `Failed to create agent: ${e.message || e}` }],
546
+ createdAt: Date.now(),
547
+ }]
548
+ })
549
+ }
550
+ }
551
+
552
+ private syncStreamingState(sessionId: string) {
553
+ if (agentService.isStreaming(sessionId)) {
554
+ const blocks = agentService.getStreamingBlocks(sessionId)
555
+ this.updateSession(sessionId, {
556
+ isStreaming: true,
557
+ streamingBlocks: [...blocks]
558
+ })
559
+ } else {
560
+ this.updateSession(sessionId, { isStreaming: false })
561
+ }
562
+ }
563
+
564
+ private handleAgentEvent(sessionId: string, agentEvent: AgentEvent) {
565
+ const eventType = agentEvent.type as string
566
+
567
+ switch (eventType) {
568
+ case 'agent_start': {
569
+ // Create partial message
570
+ const pId = `partial-${sessionId}-${Date.now()}`
571
+ agentService.setPartialMsgId(sessionId, pId)
572
+ db.addMessage(sessionId, 'assistant', '', {
573
+ id: pId,
574
+ blocks: [],
575
+ partial: true,
576
+ }).catch(e => console.error('Failed to create partial message:', e))
577
+
578
+ this.updateSession(sessionId, {
579
+ isStreaming: true,
580
+ streamingBlocks: [],
581
+ })
582
+ break
583
+ }
584
+
585
+ case 'message_start':
586
+ this.updateSession(sessionId, { isStreaming: true })
587
+ break
588
+
589
+ case 'steer_interrupt':
590
+ this.updateSession(sessionId, { streamingBlocks: [] })
591
+ break
592
+
593
+ case 'message_update':
594
+ case 'tool_execution_start':
595
+ case 'tool_execution_end':
596
+ case 'message_end':
597
+ {
598
+ const waitingUpdate: Partial<SessionState> = {}
599
+ if (this.getSession(sessionId).isWaitingForResponse && hasAssistantResponseStarted(agentEvent)) {
600
+ waitingUpdate.isWaitingForResponse = false
601
+ }
602
+ this.updateSession(sessionId, {
603
+ streamingBlocks: [...agentService.getStreamingBlocks(sessionId)],
604
+ ...waitingUpdate,
605
+ })
606
+
607
+ if (agentEvent.type === 'tool_execution_end' || agentEvent.type === 'message_end') {
608
+ const pmId = agentService.getPartialMsgId(sessionId)
609
+ if (pmId) {
610
+ const blocks = agentService.getStreamingBlocks(sessionId)
611
+ const text = blocks.filter(b => b.type === 'text' && b.content).map(b => b.content).join('')
612
+ db.updateMessage(pmId, { content: text, blocks: blocks as any }).catch(() => {})
613
+ }
614
+ }
615
+ break
616
+ }
617
+
618
+ case 'agent_end': {
619
+ const pmId = agentService.getPartialMsgId(sessionId)
620
+ const blocks = agentService.getStreamingBlocks(sessionId)
621
+ const session = this.getSession(sessionId)
622
+
623
+ // Check if the agent ended with an error (from pi-agent-core's catch block).
624
+ // The agent appends an error message with stopReason: 'error' and errorMessage.
625
+ const agentEndEvent = agentEvent as any
626
+ const agentEndMessages: any[] = agentEndEvent.messages || []
627
+ const errorMsg = agentEndMessages.find((m: any) =>
628
+ m.role === 'assistant' && (m.stopReason === 'error' || m.errorMessage)
629
+ )
630
+
631
+ if (blocks.length > 0) {
632
+ const finalMsg: DisplayMessage = {
633
+ id: pmId || `assistant-${Date.now()}`,
634
+ role: 'assistant',
635
+ blocks: [...blocks],
636
+ createdAt: Date.now(),
637
+ }
638
+
639
+ const newMessages = [...session.messages, finalMsg]
640
+
641
+ // If there was an error, also add a system message so the user sees it
642
+ if (errorMsg?.errorMessage) {
643
+ const friendly = friendlyErrorMessage({ message: errorMsg.errorMessage })
644
+ newMessages.push({
645
+ id: `err-${Date.now()}`,
646
+ role: 'system',
647
+ blocks: [{ id: `err-blk-${Date.now()}`, type: 'text', content: friendly }],
648
+ createdAt: Date.now(),
649
+ })
650
+ this.updateSession(sessionId, {
651
+ messages: newMessages,
652
+ streamingBlocks: [],
653
+ isStreaming: false,
654
+ isWaitingForResponse: false,
655
+ sendError: friendly,
656
+ }, true) // sync: streaming end
657
+ } else {
658
+ this.updateSession(sessionId, {
659
+ messages: newMessages,
660
+ streamingBlocks: [],
661
+ isStreaming: false,
662
+ isWaitingForResponse: false,
663
+ sendError: null,
664
+ }, true) // sync: streaming end
665
+ }
666
+
667
+ const textContent = blocks.filter(b => b.type === 'text' && b.content).map(b => b.content).join('')
668
+ if (pmId) {
669
+ db.updateMessage(pmId, {
670
+ content: textContent,
671
+ blocks: blocks as any,
672
+ partial: false,
673
+ }).catch(e => console.error('Failed to finalize message:', e))
674
+ } else if (textContent) {
675
+ db.addMessage(sessionId, 'assistant', textContent, {
676
+ blocks: blocks as any,
677
+ }).catch(e => console.error('Failed to save assistant message:', e))
678
+ }
679
+ } else {
680
+ if (pmId) db.removeMessage(pmId).catch(() => {})
681
+
682
+ // No blocks produced — if there was an error, surface it to the user
683
+ if (errorMsg?.errorMessage) {
684
+ const friendly = friendlyErrorMessage({ message: errorMsg.errorMessage })
685
+ this.updateSession(sessionId, {
686
+ messages: [...session.messages, {
687
+ id: `err-${Date.now()}`,
688
+ role: 'system',
689
+ blocks: [{ id: `err-blk-${Date.now()}`, type: 'text', content: friendly }],
690
+ createdAt: Date.now(),
691
+ }],
692
+ streamingBlocks: [],
693
+ isStreaming: false,
694
+ isWaitingForResponse: false,
695
+ sendError: friendly,
696
+ }, true) // sync: streaming end
697
+ } else {
698
+ this.updateSession(sessionId, {
699
+ streamingBlocks: [],
700
+ isStreaming: false,
701
+ isWaitingForResponse: false,
702
+ }, true) // sync: streaming end
703
+ }
704
+ }
705
+ agentService.clearPartialMsgId(sessionId)
706
+ break
707
+ }
708
+ }
709
+ }
710
+
711
+ /** Clear the sendError for a session (e.g., user dismissed the error banner) */
712
+ clearSendError(sessionId: string) {
713
+ this.updateSession(sessionId, { sendError: null }, true) // sync: user action
714
+ }
715
+
716
+ /** Add a system error message to the chat and update sendError state */
717
+ private addErrorToChat(sessionId: string, message: string, timestamp?: number) {
718
+ const ts = timestamp || Date.now()
719
+ const s = this.getSession(sessionId)
720
+ const errorMsg: DisplayMessage = {
721
+ id: `err-${ts}-${Math.random().toString(36).slice(2, 6)}`,
722
+ role: 'system',
723
+ blocks: [{ id: `err-blk-${ts}`, type: 'text', content: message }],
724
+ createdAt: ts,
725
+ }
726
+ this.updateSession(sessionId, {
727
+ messages: [...s.messages, errorMsg],
728
+ isStreaming: false,
729
+ isWaitingForResponse: false,
730
+ sendError: message,
731
+ }, true) // sync: error display
732
+ // Also persist the error to DB so it survives reloads
733
+ db.addMessage(sessionId, 'system', message, {
734
+ id: errorMsg.id,
735
+ }).catch(() => {})
736
+ }
737
+
738
+ async sendMessage(sessionId: string) {
739
+ const session = this.getSession(sessionId)
740
+ const input = session.input.trim()
741
+
742
+ // Guard: no empty input
743
+ if (!input) return
744
+
745
+ // Guard: agent not ready — show visible feedback instead of silently failing
746
+ if (!session.agentReady) {
747
+ this.addErrorToChat(sessionId, 'Agent is not ready yet. Please wait for initialization to complete, or check if there was an initialization error above.')
748
+ return
749
+ }
750
+
751
+ // Guard: verify the agent instance actually exists
752
+ if (!agentService.getAgent(sessionId)) {
753
+ this.addErrorToChat(sessionId, 'Agent instance not found. Try refreshing or switching sessions.')
754
+ return
755
+ }
756
+
757
+ // Clear any previous error
758
+ this.updateSession(sessionId, { sendError: null }, true) // sync: user action
759
+
760
+ const userMsgTime = Date.now()
761
+ this.updateSession(sessionId, { input: '' }, true) // sync: clear input
762
+
763
+ logAction('message.sent', { sessionId, contentPreview: input.slice(0, 100) }, { actor: 'user', sessionId })
764
+
765
+ db.addMessage(sessionId, 'user', input).catch(console.error)
766
+
767
+ // Auto-title
768
+ if (session.messages.length === 0) {
769
+ const title = input.substring(0, 30).trim() || 'New Chat'
770
+ db.updateSession(sessionId, { title }).catch(console.error)
771
+ }
772
+
773
+ if (session.isStreaming) {
774
+ // Steer logic
775
+ const currentBlocks = agentService.getStreamingBlocks(sessionId)
776
+ const pmId = agentService.getPartialMsgId(sessionId)
777
+
778
+ const newMessages = [...session.messages]
779
+ if (currentBlocks.length > 0) {
780
+ newMessages.push({
781
+ id: pmId || `assistant-interrupted-${userMsgTime - 1}`,
782
+ role: 'assistant',
783
+ blocks: [...currentBlocks],
784
+ createdAt: userMsgTime - 1,
785
+ })
786
+ }
787
+ newMessages.push({
788
+ id: `user-${userMsgTime}`,
789
+ role: 'user',
790
+ blocks: [{ id: `ublk-${userMsgTime}`, type: 'text', content: input }],
791
+ createdAt: userMsgTime,
792
+ })
793
+
794
+ this.updateSession(sessionId, {
795
+ messages: newMessages,
796
+ streamingBlocks: []
797
+ }, true) // sync: steer — user sees message immediately
798
+
799
+ if (currentBlocks.length > 0) {
800
+ const textContent = currentBlocks.filter(b => b.type === 'text' && b.content).map(b => b.content).join('')
801
+ if (pmId) {
802
+ db.updateMessage(pmId, {
803
+ content: textContent,
804
+ blocks: currentBlocks as any,
805
+ partial: false,
806
+ }).catch(console.error)
807
+ }
808
+ }
809
+
810
+ agentService.clearPartialMsgId(sessionId)
811
+
812
+ try {
813
+ agentService.steer(sessionId, input)
814
+ } catch (e: any) {
815
+ const friendly = friendlyErrorMessage(e)
816
+ this.updateSession(sessionId, {
817
+ messages: [...newMessages, {
818
+ id: `err-${userMsgTime}`,
819
+ role: 'system',
820
+ blocks: [{ id: `err-blk-${userMsgTime}`, type: 'text', content: friendly }],
821
+ createdAt: userMsgTime,
822
+ }],
823
+ isWaitingForResponse: false,
824
+ sendError: friendly,
825
+ }, true) // sync: error display
826
+ }
827
+
828
+ } else {
829
+ // Normal flow
830
+ this.updateSession(sessionId, {
831
+ messages: [...session.messages, {
832
+ id: `user-${userMsgTime}`,
833
+ role: 'user',
834
+ blocks: [{ id: `ublk-${userMsgTime}`, type: 'text', content: input }],
835
+ createdAt: userMsgTime,
836
+ }],
837
+ isStreaming: true,
838
+ isWaitingForResponse: true,
839
+ }, true) // sync: user message send
840
+
841
+ if (isE2EMockVertexResponseEnabled() && session.model.startsWith('vertex-express:')) {
842
+ const assistantTime = Date.now()
843
+ const assistantMessage: DisplayMessage = {
844
+ id: `assistant-${assistantTime}`,
845
+ role: 'assistant',
846
+ blocks: [{ id: `ablk-${assistantTime}`, type: 'text', content: 'Mock Vertex Express response.' }],
847
+ createdAt: assistantTime,
848
+ }
849
+ const current = this.getSession(sessionId)
850
+ this.updateSession(sessionId, {
851
+ messages: [...current.messages, assistantMessage],
852
+ isStreaming: false,
853
+ isWaitingForResponse: false,
854
+ sendError: null,
855
+ }, true)
856
+ db.addMessage(sessionId, 'assistant', 'Mock Vertex Express response.', {
857
+ id: assistantMessage.id,
858
+ blocks: assistantMessage.blocks as any,
859
+ }).catch(() => {})
860
+ return
861
+ }
862
+
863
+ try {
864
+ await withRetry(
865
+ async () => {
866
+ await agentService.prompt(sessionId, input)
867
+ },
868
+ {
869
+ onRetry: ({ attempt, maxRetries, error }) => {
870
+ if (!isRetryableError(error)) return
871
+ console.error(`Send attempt ${attempt} failed:`, error)
872
+ const s = this.getSession(sessionId)
873
+ const retryMsg: DisplayMessage = {
874
+ id: `retry-${userMsgTime}-${attempt}`,
875
+ role: 'system',
876
+ blocks: [{ id: `retry-blk-${userMsgTime}-${attempt}`, type: 'text', content: `Retrying... (attempt ${attempt}/${maxRetries})` }],
877
+ createdAt: Date.now(),
878
+ }
879
+ this.updateSession(sessionId, {
880
+ messages: [...s.messages, retryMsg],
881
+ isStreaming: true,
882
+ isWaitingForResponse: true,
883
+ })
884
+ },
885
+ }
886
+ )
887
+
888
+ // Success — clear any send error
889
+ this.updateSession(sessionId, { sendError: null })
890
+ } catch (e: any) {
891
+ const friendly = friendlyErrorMessage(e)
892
+ this.addErrorToChat(sessionId, friendly, userMsgTime)
893
+ }
894
+ }
895
+ }
896
+
897
+ setInput(sessionId: string, value: string) {
898
+ this.updateSession(sessionId, { input: value }, true) // sync: user typing
899
+ }
900
+
901
+ toggleTool(sessionId: string, toolId: string) {
902
+ const session = this.getSession(sessionId)
903
+ const next = new Set(session.expandedTools)
904
+ if (next.has(toolId)) next.delete(toolId)
905
+ else next.add(toolId)
906
+ this.updateSession(sessionId, { expandedTools: next }, true) // sync: user click
907
+ }
908
+ }
909
+
910
+ // Singleton instance — in moav2, entry point manages lifecycle instead of window.__sessionStore
911
+ export const sessionStore = new SessionStore()
moav2/src/core/services/session-waiting.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { AgentEvent } from '@mariozechner/pi-agent-core'
2
+
3
+ export function hasAssistantResponseStarted(agentEvent: AgentEvent): boolean {
4
+ if (agentEvent.type === 'tool_execution_start' || agentEvent.type === 'message_end') {
5
+ return true
6
+ }
7
+
8
+ if (agentEvent.type !== 'message_update') {
9
+ return false
10
+ }
11
+
12
+ const assistantEvent = (agentEvent as any).assistantMessageEvent
13
+ return !!assistantEvent
14
+ }
moav2/src/core/services/terminal-service.ts ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Terminal service - manages shell processes for terminal buffers.
2
+ // Uses node-pty for full PTY support in Electron, with a child_process fallback.
3
+ // In browser mode, all create() calls throw an error (terminal unavailable).
4
+
5
+ import { getPlatform } from '../platform'
6
+
7
+ export interface TerminalInstance {
8
+ id: string
9
+ write(data: string): void
10
+ onData(callback: (data: string) => void): void
11
+ onExit(callback: (code: number) => void): void
12
+ onTitleChange(callback: (title: string) => void): void
13
+ resize(cols: number, rows: number): void
14
+ kill(): void
15
+ }
16
+
17
+ // ---------- node-pty backend ----------
18
+
19
+ function createPtyTerminal(
20
+ id: string,
21
+ shellPath: string,
22
+ cwd: string
23
+ ): TerminalInstance {
24
+ const _require = (window as any).require
25
+ const pty = _require('node-pty')
26
+ const proc = pty.spawn(shellPath, [], {
27
+ name: 'xterm-256color',
28
+ cols: 80,
29
+ rows: 24,
30
+ cwd,
31
+ env: (window as any).process?.env || {},
32
+ })
33
+
34
+ const dataCallbacks: Array<(data: string) => void> = []
35
+ const exitCallbacks: Array<(code: number) => void> = []
36
+ const titleCallbacks: Array<(title: string) => void> = []
37
+
38
+ proc.onData((data: string) => {
39
+ for (const cb of dataCallbacks) cb(data)
40
+ })
41
+
42
+ proc.onExit(({ exitCode }: { exitCode: number }) => {
43
+ for (const cb of exitCallbacks) cb(exitCode)
44
+ })
45
+
46
+ return {
47
+ id,
48
+ write(data: string) {
49
+ proc.write(data)
50
+ },
51
+ onData(callback: (data: string) => void) {
52
+ dataCallbacks.push(callback)
53
+ },
54
+ onExit(callback: (code: number) => void) {
55
+ exitCallbacks.push(callback)
56
+ },
57
+ onTitleChange(callback: (title: string) => void) {
58
+ titleCallbacks.push(callback)
59
+ },
60
+ resize(cols: number, rows: number) {
61
+ try {
62
+ proc.resize(cols, rows)
63
+ } catch {
64
+ // Resize can fail if process already exited
65
+ }
66
+ },
67
+ kill() {
68
+ try {
69
+ proc.kill()
70
+ } catch {
71
+ // Already dead
72
+ }
73
+ },
74
+ }
75
+ }
76
+
77
+ // ---------- child_process.spawn fallback ----------
78
+
79
+ function createSpawnTerminal(
80
+ id: string,
81
+ shellPath: string,
82
+ cwd: string
83
+ ): TerminalInstance {
84
+ const _require = (window as any).require
85
+ const cp = _require('child_process') as typeof import('child_process')
86
+ const proc = cp.spawn(shellPath, [], {
87
+ cwd,
88
+ env: { ...((window as any).process?.env || {}), TERM: 'xterm-256color' },
89
+ stdio: ['pipe', 'pipe', 'pipe'],
90
+ })
91
+
92
+ const dataCallbacks: Array<(data: string) => void> = []
93
+ const exitCallbacks: Array<(code: number) => void> = []
94
+ const titleCallbacks: Array<(title: string) => void> = []
95
+
96
+ proc.stdout?.on('data', (chunk: Buffer) => {
97
+ const text = chunk.toString('utf-8')
98
+ for (const cb of dataCallbacks) cb(text)
99
+ })
100
+
101
+ proc.stderr?.on('data', (chunk: Buffer) => {
102
+ const text = chunk.toString('utf-8')
103
+ for (const cb of dataCallbacks) cb(text)
104
+ })
105
+
106
+ proc.on('exit', (code: number | null) => {
107
+ for (const cb of exitCallbacks) cb(code ?? 1)
108
+ })
109
+
110
+ return {
111
+ id,
112
+ write(data: string) {
113
+ try {
114
+ proc.stdin?.write(data)
115
+ } catch {
116
+ // stdin may be closed
117
+ }
118
+ },
119
+ onData(callback: (data: string) => void) {
120
+ dataCallbacks.push(callback)
121
+ },
122
+ onExit(callback: (code: number) => void) {
123
+ exitCallbacks.push(callback)
124
+ },
125
+ onTitleChange(callback: (title: string) => void) {
126
+ titleCallbacks.push(callback)
127
+ },
128
+ resize(_cols: number, _rows: number) {
129
+ // child_process.spawn does not support resize — no-op
130
+ },
131
+ kill() {
132
+ try {
133
+ proc.kill()
134
+ } catch {
135
+ // Already dead
136
+ }
137
+ },
138
+ }
139
+ }
140
+
141
+ // ---------- Detection ----------
142
+
143
+ let _hasPty: boolean | null = null
144
+
145
+ function hasPty(): boolean {
146
+ if (_hasPty !== null) return _hasPty
147
+ try {
148
+ const _require = (window as any).require
149
+ _require('node-pty')
150
+ _hasPty = true
151
+ } catch {
152
+ console.warn(
153
+ '[terminal-service] node-pty not available, falling back to child_process.spawn (no resize, no raw PTY)'
154
+ )
155
+ _hasPty = false
156
+ }
157
+ return _hasPty
158
+ }
159
+
160
+ // ---------- Resolve default shell and cwd ----------
161
+
162
+ function defaultShell(): string {
163
+ return getPlatform().process.env['SHELL'] || '/bin/zsh'
164
+ }
165
+
166
+ function defaultCwd(): string {
167
+ return getPlatform().process.homedir()
168
+ }
169
+
170
+ // ---------- TerminalService ----------
171
+
172
+ export class TerminalService {
173
+ private terminals: Map<string, TerminalInstance> = new Map()
174
+
175
+ create(id: string, opts?: { cwd?: string; shell?: string }): TerminalInstance {
176
+ if (getPlatform().type !== 'electron') {
177
+ throw new Error('Terminal requires desktop mode (Electron). Shell commands are available via the agent bash tool.')
178
+ }
179
+
180
+ // Return existing instance if already created (HMR survival)
181
+ const existing = this.terminals.get(id)
182
+ if (existing) return existing
183
+
184
+ const shellPath = opts?.shell || defaultShell()
185
+ const cwd = opts?.cwd || defaultCwd()
186
+
187
+ let instance: TerminalInstance
188
+ if (hasPty()) {
189
+ try {
190
+ instance = createPtyTerminal(id, shellPath, cwd)
191
+ } catch (e) {
192
+ console.warn('[terminal-service] node-pty spawn failed, falling back to child_process:', e)
193
+ instance = createSpawnTerminal(id, shellPath, cwd)
194
+ }
195
+ } else {
196
+ instance = createSpawnTerminal(id, shellPath, cwd)
197
+ }
198
+
199
+ this.terminals.set(id, instance)
200
+ return instance
201
+ }
202
+
203
+ get(id: string): TerminalInstance | undefined {
204
+ return this.terminals.get(id)
205
+ }
206
+
207
+ destroy(id: string): void {
208
+ const inst = this.terminals.get(id)
209
+ if (inst) {
210
+ inst.kill()
211
+ this.terminals.delete(id)
212
+ }
213
+ }
214
+
215
+ destroyAll(): void {
216
+ for (const [id] of this.terminals) {
217
+ this.destroy(id)
218
+ }
219
+ }
220
+ }
221
+
222
+ // HMR-safe singleton — survives Vite hot-module replacement
223
+ const _w = window as any
224
+ if (!_w.__terminalService) _w.__terminalService = new TerminalService()
225
+ export const terminalService: TerminalService = _w.__terminalService
moav2/src/core/services/tools/index.ts ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ // Stub — tools will be implemented in a later task.
2
+ // This module provides the createAllTools function that agent-service.ts imports.
3
+
4
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
5
+
6
+ export function createAllTools(): AgentTool<any, any>[] {
7
+ // Tools will be registered here once implemented
8
+ return []
9
+ }
moav2/src/core/tools/bash-tool.ts ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getPlatform } from '../platform'
2
+ import { Type } from '@sinclair/typebox'
3
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
4
+ import { logAction } from '../services/action-logger'
5
+
6
+ export function createBashTool(): AgentTool<any, any> {
7
+ return {
8
+ name: 'bash',
9
+ label: 'Execute Command',
10
+ description: 'Execute a shell command and return its output.',
11
+ parameters: Type.Object({
12
+ command: Type.String({ description: 'The shell command to execute' }),
13
+ }),
14
+ execute: async (toolCallId, params) => {
15
+ try {
16
+ const { process: proc } = getPlatform()
17
+ const result = await proc.exec(params.command, { timeout: 30000, maxBuffer: 1024 * 1024 })
18
+ logAction('tool.bash', {
19
+ command: params.command,
20
+ exitCode: result.exitCode,
21
+ outputLength: result.stdout.length,
22
+ }, { actor: 'agent' })
23
+
24
+ if (result.exitCode !== 0) {
25
+ return {
26
+ content: [{ type: 'text', text: `stdout:\n${result.stdout}\nstderr:\n${result.stderr}` }],
27
+ details: { command: params.command, exitCode: result.exitCode, error: result.stderr },
28
+ }
29
+ }
30
+ return {
31
+ content: [{ type: 'text', text: result.stdout }],
32
+ details: { command: params.command, exitCode: 0 },
33
+ }
34
+ } catch (e: any) {
35
+ return {
36
+ content: [{ type: 'text', text: `Error: ${e.message}` }],
37
+ details: { command: params.command, exitCode: 1, error: e.message },
38
+ }
39
+ }
40
+ },
41
+ }
42
+ }
moav2/src/core/tools/edit-tool.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { getPlatform } from '../platform'
2
+ import { Type } from '@sinclair/typebox'
3
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
4
+ import { logAction } from '../services/action-logger'
5
+
6
+ export function createEditTool(): AgentTool<any, any> {
7
+ return {
8
+ name: 'edit',
9
+ label: 'Edit File',
10
+ description: 'Replace a specific string in a file with a new string.',
11
+ parameters: Type.Object({
12
+ path: Type.String({ description: 'Absolute path to the file to edit' }),
13
+ old_string: Type.String({ description: 'The exact string to find and replace' }),
14
+ new_string: Type.String({ description: 'The replacement string' }),
15
+ }),
16
+ execute: async (toolCallId, params) => {
17
+ try {
18
+ const { fs } = getPlatform()
19
+ const content = fs.readFileSync(params.path, 'utf-8')
20
+ const idx = content.indexOf(params.old_string)
21
+ if (idx === -1) {
22
+ return {
23
+ content: [{ type: 'text', text: `Error: old_string not found in ${params.path}` }],
24
+ details: { error: 'old_string not found' },
25
+ }
26
+ }
27
+ const newContent = content.substring(0, idx) + params.new_string + content.substring(idx + params.old_string.length)
28
+ fs.writeFileSync(params.path, newContent)
29
+ logAction('tool.edit', {
30
+ path: params.path,
31
+ oldLength: params.old_string.length,
32
+ newLength: params.new_string.length,
33
+ }, { actor: 'agent' })
34
+ return {
35
+ content: [{ type: 'text', text: `Edited ${params.path}: replaced ${params.old_string.length} chars with ${params.new_string.length} chars` }],
36
+ details: { path: params.path },
37
+ }
38
+ } catch (e: any) {
39
+ return {
40
+ content: [{ type: 'text', text: `Error editing file: ${e.message}` }],
41
+ details: { error: e.message },
42
+ }
43
+ }
44
+ },
45
+ }
46
+ }
moav2/src/core/tools/history-tool.ts ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Type } from '@sinclair/typebox'
2
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
3
+ import { db, dbReady } from '../services/db'
4
+
5
+ export function createHistoryTool(): AgentTool<any, any> {
6
+ return {
7
+ name: 'history',
8
+ label: 'Conversation History',
9
+ description:
10
+ 'Browse and search conversation history across all sessions. ' +
11
+ 'Actions: "list_sessions" (list all sessions with titles/dates), ' +
12
+ '"get_session" (get messages from a specific session by sessionId), ' +
13
+ '"search_all" (search across all session messages for a query), ' +
14
+ '"get_recent" (get the N most recent messages across all sessions).',
15
+ parameters: Type.Object({
16
+ action: Type.String({
17
+ description: 'Action: "list_sessions", "get_session", "search_all", "get_recent"',
18
+ }),
19
+ sessionId: Type.Optional(Type.String({ description: 'Session ID (required for get_session)' })),
20
+ query: Type.Optional(Type.String({ description: 'Search query (required for search_all)' })),
21
+ count: Type.Optional(Type.Number({ description: 'Number of results to return (default: 20, used by get_recent and search_all)' })),
22
+ }),
23
+ execute: async (_toolCallId, params) => {
24
+ try {
25
+ await dbReady
26
+
27
+ switch (params.action) {
28
+ case 'list_sessions': {
29
+ const sessions = await db.listSessions()
30
+ if (sessions.length === 0) {
31
+ return {
32
+ content: [{ type: 'text', text: 'No conversation sessions found.' }],
33
+ details: { count: 0 },
34
+ }
35
+ }
36
+
37
+ const lines = sessions.map((s) => {
38
+ const created = new Date(s.createdAt).toISOString().slice(0, 16)
39
+ const updated = new Date(s.updatedAt).toISOString().slice(0, 16)
40
+ return `[${s.id}] "${s.title}" (model: ${s.model || 'unknown'})\n Created: ${created} Updated: ${updated}`
41
+ })
42
+
43
+ return {
44
+ content: [{ type: 'text', text: `${sessions.length} session(s):\n\n${lines.join('\n\n')}` }],
45
+ details: { count: sessions.length },
46
+ }
47
+ }
48
+
49
+ case 'get_session': {
50
+ if (!params.sessionId) {
51
+ return {
52
+ content: [{ type: 'text', text: 'Error: "sessionId" is required for get_session action.' }],
53
+ details: {},
54
+ }
55
+ }
56
+
57
+ const session = await db.getSession(params.sessionId)
58
+ if (!session) {
59
+ return {
60
+ content: [{ type: 'text', text: `Session not found: ${params.sessionId}` }],
61
+ details: {},
62
+ }
63
+ }
64
+
65
+ const messages = await db.getMessages(params.sessionId)
66
+ if (messages.length === 0) {
67
+ return {
68
+ content: [{ type: 'text', text: `Session "${session.title}" exists but has no messages.` }],
69
+ details: { session },
70
+ }
71
+ }
72
+
73
+ const lines = messages
74
+ .filter((m) => !m.partial)
75
+ .map((m) => {
76
+ const time = new Date(m.createdAt).toISOString().slice(0, 16)
77
+ const content = m.content || ''
78
+ const snippet = content.length > 500 ? content.substring(0, 500) + '...' : content
79
+ return `[${time}] ${m.role}: ${snippet}`
80
+ })
81
+
82
+ return {
83
+ content: [{
84
+ type: 'text',
85
+ text: `Session "${session.title}" (${messages.length} messages):\n\n${lines.join('\n\n')}`,
86
+ }],
87
+ details: { session, messageCount: messages.length },
88
+ }
89
+ }
90
+
91
+ case 'search_all': {
92
+ if (!params.query) {
93
+ return {
94
+ content: [{ type: 'text', text: 'Error: "query" is required for search_all action.' }],
95
+ details: {},
96
+ }
97
+ }
98
+
99
+ const maxResults = params.count || 20
100
+ const query = params.query.toLowerCase()
101
+ const sessions = await db.listSessions()
102
+ const results: string[] = []
103
+
104
+ for (const session of sessions) {
105
+ if (results.length >= maxResults) break
106
+ const messages = await db.getMessages(session.id)
107
+ for (const msg of messages) {
108
+ if (results.length >= maxResults) break
109
+ if (msg.partial) continue
110
+ const content = msg.content || ''
111
+ if (content.toLowerCase().includes(query)) {
112
+ const date = new Date(msg.createdAt).toISOString().slice(0, 16)
113
+ const snippet = content.length > 300 ? content.substring(0, 300) + '...' : content
114
+ results.push(`[${date}] Session "${session.title}" (${session.id}) [${msg.role}]:\n ${snippet}`)
115
+ }
116
+ }
117
+ }
118
+
119
+ if (results.length === 0) {
120
+ return {
121
+ content: [{ type: 'text', text: `No matches for "${params.query}" across all sessions.` }],
122
+ details: { query: params.query, matchCount: 0 },
123
+ }
124
+ }
125
+
126
+ return {
127
+ content: [{
128
+ type: 'text',
129
+ text: `Found ${results.length} match(es) for "${params.query}":\n\n${results.join('\n\n')}`,
130
+ }],
131
+ details: { query: params.query, matchCount: results.length },
132
+ }
133
+ }
134
+
135
+ case 'get_recent': {
136
+ const count = params.count || 20
137
+ const sessions = await db.listSessions()
138
+
139
+ // Collect all non-partial messages across sessions, then sort by time and take most recent
140
+ const allMessages: Array<{ sessionTitle: string; sessionId: string; role: string; content: string; createdAt: number }> = []
141
+
142
+ for (const session of sessions) {
143
+ const messages = await db.getMessages(session.id)
144
+ for (const msg of messages) {
145
+ if (msg.partial) continue
146
+ allMessages.push({
147
+ sessionTitle: session.title,
148
+ sessionId: session.id,
149
+ role: msg.role,
150
+ content: msg.content || '',
151
+ createdAt: msg.createdAt,
152
+ })
153
+ }
154
+ }
155
+
156
+ // Sort by most recent first
157
+ allMessages.sort((a, b) => b.createdAt - a.createdAt)
158
+ const recent = allMessages.slice(0, count)
159
+
160
+ if (recent.length === 0) {
161
+ return {
162
+ content: [{ type: 'text', text: 'No messages found across any sessions.' }],
163
+ details: { count: 0 },
164
+ }
165
+ }
166
+
167
+ const lines = recent.map((m) => {
168
+ const date = new Date(m.createdAt).toISOString().slice(0, 16)
169
+ const snippet = m.content.length > 300 ? m.content.substring(0, 300) + '...' : m.content
170
+ return `[${date}] Session "${m.sessionTitle}" [${m.role}]:\n ${snippet}`
171
+ })
172
+
173
+ return {
174
+ content: [{
175
+ type: 'text',
176
+ text: `${recent.length} most recent message(s):\n\n${lines.join('\n\n')}`,
177
+ }],
178
+ details: { count: recent.length },
179
+ }
180
+ }
181
+
182
+ default:
183
+ return {
184
+ content: [{
185
+ type: 'text',
186
+ text: `Unknown action: "${params.action}". Valid actions: list_sessions, get_session, search_all, get_recent`,
187
+ }],
188
+ details: {},
189
+ }
190
+ }
191
+ } catch (e: any) {
192
+ return {
193
+ content: [{ type: 'text', text: `History error: ${e.message}` }],
194
+ details: { error: e.message },
195
+ }
196
+ }
197
+ },
198
+ }
199
+ }
moav2/src/core/tools/index.ts ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createReadTool } from './read-tool'
2
+ import { createWriteTool } from './write-tool'
3
+ import { createEditTool } from './edit-tool'
4
+ import { createBashTool } from './bash-tool'
5
+ import { createSearchTool } from './search-tool'
6
+ import { createSelfEditTool } from './self-edit-tool'
7
+ import { createWebFetchTool } from './web-fetch-tool'
8
+ import { createIntentTool } from './intent-tool'
9
+ import { createHistoryTool } from './history-tool'
10
+ import { createRuntimePackTool } from './runtime-pack-tool'
11
+ import { createSetSystemPromptTool } from './set-system-prompt-tool'
12
+ import type { AgentTool } from '@mariozechner/pi-agent-core'
13
+
14
+ export function createAllTools(moaSrcPath: string = '/src'): AgentTool<any, any>[] {
15
+ return [
16
+ createReadTool(),
17
+ createWriteTool(),
18
+ createEditTool(),
19
+ createBashTool(),
20
+ createSearchTool(),
21
+ createSelfEditTool(moaSrcPath),
22
+ createWebFetchTool(),
23
+ createIntentTool(),
24
+ createHistoryTool(),
25
+ createRuntimePackTool(),
26
+ ]
27
+ }
28
+
29
+ export {
30
+ createReadTool,
31
+ createWriteTool,
32
+ createEditTool,
33
+ createBashTool,
34
+ createSearchTool,
35
+ createSelfEditTool,
36
+ createWebFetchTool,
37
+ createIntentTool,
38
+ createHistoryTool,
39
+ createRuntimePackTool,
40
+ createSetSystemPromptTool,
41
+ }