Spaces:
Sleeping
Sleeping
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 filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +11 -2
- moav2/package.json +75 -0
- moav2/src/__tests__/agent-buffer-no-model.test.tsx +40 -0
- moav2/src/__tests__/agent-buffer-ui.test.tsx +73 -0
- moav2/src/__tests__/browser-buffer-capacitor.test.tsx +90 -0
- moav2/src/__tests__/browser-history.test.ts +596 -0
- moav2/src/__tests__/capacitor-boot.test.ts +56 -0
- moav2/src/__tests__/capacitor-browser.test.ts +84 -0
- moav2/src/__tests__/capacitor-mini-shell.test.ts +157 -0
- moav2/src/__tests__/cli-history.test.ts +505 -0
- moav2/src/__tests__/command-palette-utils.test.ts +308 -0
- moav2/src/__tests__/command-palette.test.tsx +463 -0
- moav2/src/__tests__/dropdown.test.tsx +108 -0
- moav2/src/__tests__/event-store.test.ts +483 -0
- moav2/src/__tests__/model-resolver-openai-oauth.test.ts +35 -0
- moav2/src/__tests__/oauth-code-bridge.test.ts +43 -0
- moav2/src/__tests__/pin-system.test.ts +302 -0
- moav2/src/__tests__/platform-boot.test.ts +109 -0
- moav2/src/__tests__/provider-smoke.test.ts +49 -0
- moav2/src/__tests__/retry.test.ts +121 -0
- moav2/src/__tests__/scroll-behavior.test.ts +293 -0
- moav2/src/__tests__/session-store-waiting.test.ts +24 -0
- moav2/src/__tests__/set-system-prompt-tool.test.ts +26 -0
- moav2/src/__tests__/terminal-buffer-capacitor.test.tsx +27 -0
- moav2/src/__tests__/tools.test.ts +220 -0
- moav2/src/__tests__/window-constraints.test.ts +17 -0
- moav2/src/cli/db-reader.ts +236 -0
- moav2/src/cli/history.ts +127 -0
- moav2/src/cli/index.ts +169 -0
- moav2/src/core/platform/index.ts +16 -0
- moav2/src/core/platform/types.ts +61 -0
- moav2/src/core/services/action-logger.ts +45 -0
- moav2/src/core/services/agent-service.ts +312 -0
- moav2/src/core/services/db.ts +377 -0
- moav2/src/core/services/event-store.ts +449 -0
- moav2/src/core/services/google-auth.ts +226 -0
- moav2/src/core/services/google-vertex-express.ts +372 -0
- moav2/src/core/services/model-resolver.ts +91 -0
- moav2/src/core/services/oauth-code-bridge.ts +42 -0
- moav2/src/core/services/provider-guards.ts +43 -0
- moav2/src/core/services/retry.ts +134 -0
- moav2/src/core/services/runtime-pack.ts +348 -0
- moav2/src/core/services/session-store.ts +911 -0
- moav2/src/core/services/session-waiting.ts +14 -0
- moav2/src/core/services/terminal-service.ts +225 -0
- moav2/src/core/services/tools/index.ts +9 -0
- moav2/src/core/tools/bash-tool.ts +42 -0
- moav2/src/core/tools/edit-tool.ts +46 -0
- moav2/src/core/tools/history-tool.ts +199 -0
- 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
|
| 4 |
-
RUN apt-get update && apt-get install -y
|
|
|
|
|
|
|
|
|
|
| 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">&</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 |
+
}
|